From 8a29ae50bc69c7b839a5838a7995cc1c015a7d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Fri, 18 Sep 2026 19:37:47 +0200 Subject: [PATCH 1/7] test: add end-to-end smoke tests covering every demo activity Reviewing a pull request currently means cloning it and clicking through the sample app by hand to confirm nothing regressed. These two tests do that walk automatically. DemoAppSmokeTest is parameterized over allActivityGroups, the same registry that builds the on-screen demo list, so a demo added to the app is covered with no change to the test. Each demo is launched and checked for three things: it reaches RESUMED, a MapView or StreetViewPanoramaView is attached and laid out, and nothing crashes on a background thread. It then recreates the activity and checks again, which is where camera and marker state holders tend to regress. DemoRegistryTest guards the registry itself. A demo is described both in allActivityGroups and in AndroidManifest.xml; adding it to one and not the other builds cleanly and only fails when someone taps the entry. Map content is deliberately not asserted. Verifying that a particular marker or overlay is drawn belongs in the focused tests alongside these; the value here is breadth. --- .../maps/android/compose/DemoAppSmokeTest.kt | 189 ++++++++++++++++++ .../maps/android/compose/DemoRegistryTest.kt | 86 ++++++++ 2 files changed, 275 insertions(+) create mode 100644 maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt create mode 100644 maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt new file mode 100644 index 00000000..af196156 --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt @@ -0,0 +1,189 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose + +import android.view.View +import android.view.ViewGroup +import androidx.activity.ComponentActivity +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.StreetViewPanoramaView +import com.google.common.truth.Truth.assertThat +import org.junit.After +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import java.util.concurrent.CopyOnWriteArrayList + +/** + * End-to-end smoke test covering every demo in the sample app. + * + * The parameter list is derived from [allActivityGroups], the same registry that builds the + * demo list on screen, so a demo added to the app is covered here automatically with no + * change to this file. That is the point of the test: it is a standing guarantee that every + * screen a reviewer would otherwise open by hand still launches, renders a map and survives a + * configuration change. + * + * Each demo is checked for three things: + * - it reaches [Lifecycle.State.RESUMED] without throwing, + * - a map surface ([MapView] or [StreetViewPanoramaView]) is attached and laid out, + * - nothing crashes on a background thread while it is open. + * + * This deliberately does not assert on map *content*. Verifying that a particular marker or + * overlay is drawn belongs in the focused tests next to this one; the value here is breadth. + */ +@RunWith(Parameterized::class) +class DemoAppSmokeTest( + private val demoName: String, + private val demoActivity: Class, +) { + + companion object { + /** + * How long to wait for a demo's map surface to be attached and laid out. Generous + * because the first demo to run on a cold emulator pays for Maps SDK initialisation. + */ + private const val MAP_SURFACE_TIMEOUT_MS = 20_000L + + private const val POLL_INTERVAL_MS = 250L + + /** + * Demos that legitimately show no map surface of their own. Keep this empty unless a + * demo really is map-free; an entry here is a hole in the coverage, not a fix. + */ + private val DEMOS_WITHOUT_MAP_SURFACE = emptySet() + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun demos(): List> = + allActivityGroups + .flatMap { group -> group.activities } + .map { activity -> + @Suppress("UNCHECKED_CAST") + arrayOf( + activity.kClass.simpleName ?: activity.kClass.java.name, + activity.kClass.java as Class, + ) + } + } + + private val uncaughtExceptions = CopyOnWriteArrayList() + private var defaultHandler: Thread.UncaughtExceptionHandler? = null + + @Before + fun setUp() { + // Without a key the Maps SDK renders an empty grid, so every map assertion below would + // be meaningless. Skip rather than fail: forks run CI without access to the secret. + assumeTrue("Maps API key not specified", hasValidApiKey) + + defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + uncaughtExceptions.add(throwable) + } + } + + @After + fun tearDown() { + Thread.setDefaultUncaughtExceptionHandler(defaultHandler) + } + + @Test + fun demoLaunchesAndShowsMap() { + ActivityScenario.launch(demoActivity).use { scenario -> + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP_SURFACE) { + scenario.awaitMapSurface() + } + assertNoUncaughtExceptions() + } + } + + /** + * Rotation and process-level configuration changes are where camera and marker state + * holders tend to regress, and they are easy to miss when clicking through the app by hand. + */ + @Test + fun demoSurvivesConfigurationChange() { + ActivityScenario.launch(demoActivity).use { scenario -> + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP_SURFACE) { + scenario.awaitMapSurface() + } + + scenario.recreate() + + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP_SURFACE) { + scenario.awaitMapSurface() + } + assertNoUncaughtExceptions() + } + } + + private fun ActivityScenario.assertResumed() { + assertThat(state).isEqualTo(Lifecycle.State.RESUMED) + } + + /** + * Polls until the demo has a map surface that is attached, visible and non-zero sized. + * Polling rather than a single check because [MapView] is created from an `AndroidView` + * factory and laid out a frame or more after the activity resumes. + */ + private fun ActivityScenario.awaitMapSurface() { + val deadline = System.currentTimeMillis() + MAP_SURFACE_TIMEOUT_MS + var surfaces = emptyList() + + while (System.currentTimeMillis() < deadline) { + onActivity { activity -> + surfaces = activity.window.decorView.mapSurfaces() + } + if (surfaces.any { it.isShown && it.width > 0 && it.height > 0 }) return + Thread.sleep(POLL_INTERVAL_MS) + } + + val detail = if (surfaces.isEmpty()) { + "no MapView or StreetViewPanoramaView was found in the view hierarchy" + } else { + surfaces.joinToString(prefix = "found but not laid out: ") { surface -> + "${surface.javaClass.simpleName}" + + "(shown=${surface.isShown}, ${surface.width}x${surface.height})" + } + } + throw AssertionError( + "$demoName did not show a map within ${MAP_SURFACE_TIMEOUT_MS}ms: $detail", + ) + } + + private fun assertNoUncaughtExceptions() { + val failure = uncaughtExceptions.firstOrNull() ?: return + throw AssertionError("$demoName crashed on a background thread", failure) + } + + /** Depth-first walk collecting every Maps SDK surface below this view. */ + private fun View.mapSurfaces(): List = buildList { + fun walk(view: View) { + if (view is MapView || view is StreetViewPanoramaView) add(view) + if (view is ViewGroup) { + for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + } + walk(this@mapSurfaces) + } +} diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt new file mode 100644 index 00000000..ad943f91 --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose + +import android.content.ComponentName +import android.content.pm.PackageManager +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Guards the demo registry itself. + * + * A demo is described in two places that have to agree: [allActivityGroups], which builds the + * on-screen list, and `AndroidManifest.xml`, which declares the activity. Adding a demo to one + * and not the other builds cleanly and only fails when someone taps the entry, which is exactly + * the kind of breakage a reviewer has to catch by hand today. + */ +class DemoRegistryTest { + + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val demos = allActivityGroups.flatMap { group -> group.activities } + + @Test + fun everyDemoIsDeclaredInTheManifest() { + val undeclared = demos.filterNot { demo -> + val component = ComponentName(context, demo.kClass.java) + runCatching { + context.packageManager.getActivityInfo(component, 0) + }.isSuccess + } + + assertThat(undeclared.map { it.kClass.simpleName }).isEmpty() + } + + /** + * The demo list launches each entry with a bare [android.content.Intent], which only works + * for activities the system will start from the sample's own task. + */ + @Test + fun everyDemoIsLaunchable() { + val notLaunchable = demos.filterNot { demo -> + val component = ComponentName(context, demo.kClass.java) + val info = runCatching { + context.packageManager.getActivityInfo(component, PackageManager.MATCH_ALL) + }.getOrNull() + info != null && info.isEnabled + } + + assertThat(notLaunchable.map { it.kClass.simpleName }).isEmpty() + } + + @Test + fun everyDemoHasATitleAndDescription() { + val missingStrings = demos.filter { demo -> + context.getString(demo.title).isBlank() || context.getString(demo.description).isBlank() + } + + assertThat(missingStrings.map { it.kClass.simpleName }).isEmpty() + } + + @Test + fun noDemoIsRegisteredTwice() { + val duplicates = demos + .groupBy { it.kClass } + .filterValues { it.size > 1 } + .keys + .map { it.simpleName } + + assertThat(duplicates).isEmpty() + } +} From 855b031db25475bd87c19c16e065c8f8fec72e0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 12:34:47 +0200 Subject: [PATCH 2/7] ci: run instrumentation tests on API 36 and require maps to load Emulators never update Google Play services. The bundled version on older images crashes inside Play services when adding advanced markers. When the Maps API key secret is available, the demo smoke test also waits for map tiles to render. (cherry picked from commit 22d2871c114f0450e6c8387ee74ac1faf57459f0) --- .github/workflows/instrumentation-test.yml | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/.github/workflows/instrumentation-test.yml b/.github/workflows/instrumentation-test.yml index 9bb4318f..9d9494ea 100644 --- a/.github/workflows/instrumentation-test.yml +++ b/.github/workflows/instrumentation-test.yml @@ -45,7 +45,14 @@ jobs: env: MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }} run: | - [ -z "$MAPS_API_KEY" ] && MAPS_API_KEY="YOUR_API_KEY"; echo "MAPS_API_KEY=$MAPS_API_KEY" >> ./secrets.properties + # Tiles only render with a real key, so only then does the demo smoke test wait for maps to load. + if [ -z "$MAPS_API_KEY" ]; then + MAPS_API_KEY="YOUR_API_KEY" + echo "REQUIRE_MAP_LOADED=false" >> "$GITHUB_ENV" + else + echo "REQUIRE_MAP_LOADED=true" >> "$GITHUB_ENV" + fi + echo "MAPS_API_KEY=$MAPS_API_KEY" >> ./secrets.properties - name: Build debug run: ./gradlew assembleDebug @@ -53,11 +60,14 @@ jobs: - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: - api-level: 29 + # Emulators never update Google Play services, so the image's bundled version is what the + # maps run on. Older images bundle Play services where adding advanced markers crashes + # inside Play services. API 36 images bundle 25.26, which works. + api-level: 36 target: google_apis - arch: x86 + arch: x86_64 disable-animations: true - script: ./gradlew createDebugCoverageReport --stacktrace + script: ./gradlew createDebugCoverageReport --stacktrace -Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=$REQUIRE_MAP_LOADED # Stage the coverage reports flat, named after their module, and publish # them so the post-merge "Record coverage history" workflow can read them From c68cdbe559fc26182a0732ed18515c2947f79888 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 13:07:34 +0200 Subject: [PATCH 3/7] test: zoom each demo's map and optionally wait for tiles in the smoke test Zooming out and back in exercises the camera listeners the demos and the library hook into. With requireMapLoaded=true, which CI passes when the Maps API key secret is available, the test also waits for tiles to render. --- .../maps/android/compose/DemoAppSmokeTest.kt | 64 +++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt index af196156..92115d99 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt @@ -21,9 +21,13 @@ import android.view.ViewGroup import androidx.activity.ComponentActivity import androidx.lifecycle.Lifecycle import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import com.google.android.gms.maps.CameraUpdateFactory +import com.google.android.gms.maps.GoogleMap import com.google.android.gms.maps.MapView import com.google.android.gms.maps.StreetViewPanoramaView import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage import org.junit.After import org.junit.Assume.assumeTrue import org.junit.Before @@ -31,6 +35,8 @@ import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit /** * End-to-end smoke test covering every demo in the sample app. @@ -44,6 +50,8 @@ import java.util.concurrent.CopyOnWriteArrayList * Each demo is checked for three things: * - it reaches [Lifecycle.State.RESUMED] without throwing, * - a map surface ([MapView] or [StreetViewPanoramaView]) is attached and laid out, + * - its map survives a zoom out and back in, which exercises the camera listeners the demos and + * the library hook into, * - nothing crashes on a background thread while it is open. * * This deliberately does not assert on map *content*. Verifying that a particular marker or @@ -64,6 +72,12 @@ class DemoAppSmokeTest( private const val POLL_INTERVAL_MS = 250L + /** How long to wait for tiles to render when `requireMapLoaded` is set. */ + private const val MAP_LOADED_TIMEOUT_MS = 30_000L + + /** Time given to a demo to react to a camera change before the next check. */ + private const val SETTLE_MS = 1_500L + /** * Demos that legitimately show no map surface of their own. Keep this empty unless a * demo really is map-free; an entry here is a hole in the coverage, not a fix. @@ -84,6 +98,14 @@ class DemoAppSmokeTest( } } + /** + * Tiles only render with a real key, so waiting for them is opt-in: pass + * `-Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true`. CI does when the key + * secret is available. + */ + private val requireMapLoaded = + InstrumentationRegistry.getArguments().getString("requireMapLoaded").toBoolean() + private val uncaughtExceptions = CopyOnWriteArrayList() private var defaultHandler: Thread.UncaughtExceptionHandler? = null @@ -110,7 +132,9 @@ class DemoAppSmokeTest( scenario.assertResumed() if (demoName !in DEMOS_WITHOUT_MAP_SURFACE) { scenario.awaitMapSurface() + scenario.zoomOutAndBackIn() } + scenario.assertResumed() assertNoUncaughtExceptions() } } @@ -171,6 +195,46 @@ class DemoAppSmokeTest( ) } + /** + * Zooms the demo's map out and back in. When [requireMapLoaded] is set, also waits for the + * tiles to render each time. The `GoogleMap` composable registers its loaded callback once, + * so replacing it means a demo's own `onMapLoaded` stops firing for the rest of the test, + * which only affects UI such as loading indicators. Demos showing Street View are skipped. + */ + private fun ActivityScenario.zoomOutAndBackIn() { + var mapView: MapView? = null + onActivity { activity -> + mapView = activity.window.decorView.mapSurfaces().filterIsInstance().firstOrNull() + } + val view = mapView ?: return + + var map: GoogleMap? = null + val ready = CountDownLatch(1) + onActivity { + view.getMapAsync { + map = it + ready.countDown() + } + } + assertWithMessage("$demoName map was not ready within ${MAP_SURFACE_TIMEOUT_MS}ms") + .that(ready.await(MAP_SURFACE_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue() + + for (delta in floatArrayOf(-1f, 1f)) { + val loaded = CountDownLatch(1) + onActivity { + if (requireMapLoaded) map!!.setOnMapLoadedCallback { loaded.countDown() } + map!!.moveCamera(CameraUpdateFactory.zoomBy(delta)) + } + if (requireMapLoaded) { + assertWithMessage("$demoName map did not finish loading after zooming") + .that(loaded.await(MAP_LOADED_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue() + } + Thread.sleep(SETTLE_MS) + } + } + private fun assertNoUncaughtExceptions() { val failure = uncaughtExceptions.firstOrNull() ?: return throw AssertionError("$demoName crashed on a background thread", failure) From 12db67e7c143541d090ea547df2e3b8b37fc8e7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 13:07:34 +0200 Subject: [PATCH 4/7] ci: run instrumentation tests on Ubuntu with KVM On macOS-latest-large the API 36 emulator took over five minutes to boot and the job hit its 30 minute timeout. Ubuntu runners support KVM and boot it in about a minute. Raise the timeout to 45 minutes for headroom. --- .github/workflows/instrumentation-test.yml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.github/workflows/instrumentation-test.yml b/.github/workflows/instrumentation-test.yml index 9d9494ea..d56388e2 100644 --- a/.github/workflows/instrumentation-test.yml +++ b/.github/workflows/instrumentation-test.yml @@ -24,10 +24,12 @@ on: jobs: run-instrumentation-test: - runs-on: macOS-latest-large # enables hardware acceleration in the virtual machine + # Ubuntu runners support KVM, which boots the API 36 emulator in about a minute. On + # macOS-latest-large it took over five minutes and the job hit its timeout. + runs-on: ubuntu-latest permissions: pull-requests: write - timeout-minutes: 30 + timeout-minutes: 45 steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -57,6 +59,12 @@ jobs: - name: Build debug run: ./gradlew assembleDebug + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: From ec2e7aa3ae46a4e4bb9d256ba10a0d95540d9b95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 13:18:58 +0200 Subject: [PATCH 5/7] test: open every demo through the menu and navigate back DemoMenuNavigationTest starts MainActivity, taps the demo's group and entry, waits for its map, and presses back to the menu, so a broken menu entry, click handler or back navigation is caught. DemoRegistryTest now also fails when a demo activity is declared in the manifest but missing from the menu. Adds test tags to the menu cards. --- .../android/compose/DemoMenuNavigationTest.kt | 153 ++++++++++++++++++ .../maps/android/compose/DemoRegistryTest.kt | 22 +++ .../com/google/maps/android/compose/Demo.kt | 9 ++ 3 files changed, 184 insertions(+) create mode 100644 maps-app/src/androidTest/java/com/google/maps/android/compose/DemoMenuNavigationTest.kt diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoMenuNavigationTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoMenuNavigationTest.kt new file mode 100644 index 00000000..dc9fabc6 --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoMenuNavigationTest.kt @@ -0,0 +1,153 @@ +/* + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.google.maps.android.compose + +import android.app.Activity +import android.os.SystemClock +import android.view.View +import android.view.ViewGroup +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performScrollTo +import androidx.test.espresso.Espresso +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry +import androidx.test.runner.lifecycle.Stage +import com.google.android.gms.maps.MapView +import com.google.common.truth.Truth.assertWithMessage +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.Parameterized +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import com.google.maps.android.compose.Activity as Demo + +/** + * Walks the sample app the way a user does: starts [MainActivity], expands the demo's group, + * taps the demo, waits for its map, and presses back to return to the menu. + * + * [DemoAppSmokeTest] launches each demo directly to check it in depth. This test covers the path + * a reviewer actually takes, so a broken menu entry, click handler or back navigation fails here. + * The parameters come from [allActivityGroups], so new demos are covered automatically. + */ +@RunWith(Parameterized::class) +class DemoMenuNavigationTest( + private val group: ActivityGroup, + private val demo: Demo, + @Suppress("unused") private val name: String, +) { + @get:Rule + val composeRule = createAndroidComposeRule() + + private val instrumentation = InstrumentationRegistry.getInstrumentation() + + @Test + fun opensFromMenuAndNavigatesBack() { + val demoClass = demo.kClass.java + + composeRule.onNodeWithTag(groupTestTag(group)).performClick() + composeRule.onNodeWithTag(demoTestTag(demo)).performScrollTo().performClick() + + val activity = waitForResumedActivity(demoClass) + if (demoClass !in DEMOS_WITHOUT_MAP) { + awaitMapReady(activity) + } + + assertWithMessage("${demoClass.simpleName} finished by itself") + .that(activity.isFinishing) + .isFalse() + + Espresso.pressBackUnconditionally() + waitForResumedActivity(MainActivity::class.java) + } + + private fun resumedActivity(): Activity? { + var resumed: Activity? = null + instrumentation.runOnMainSync { + resumed = ActivityLifecycleMonitorRegistry + .getInstance() + .getActivitiesInStage(Stage.RESUMED) + .firstOrNull() + } + return resumed + } + + private fun waitForResumedActivity(activityClass: Class): Activity { + val deadline = SystemClock.uptimeMillis() + TIMEOUT_MS + while (SystemClock.uptimeMillis() < deadline) { + val resumed = resumedActivity() + if (resumed != null && resumed.javaClass == activityClass) { + return resumed + } + SystemClock.sleep(POLL_MS) + } + throw AssertionError( + "${activityClass.simpleName} was not resumed within ${TIMEOUT_MS}ms, " + + "the foreground activity is ${resumedActivity()?.javaClass?.simpleName}" + ) + } + + /** Finds the first [MapView] in [activity] and waits for its map to be ready. */ + private fun awaitMapReady(activity: Activity) { + val deadline = SystemClock.uptimeMillis() + TIMEOUT_MS + val ready = CountDownLatch(1) + var requested = false + while (!requested && SystemClock.uptimeMillis() < deadline) { + instrumentation.runOnMainSync { + val mapView = findMapView(activity.window.decorView) ?: return@runOnMainSync + mapView.getMapAsync { ready.countDown() } + requested = true + } + if (!requested) SystemClock.sleep(POLL_MS) + } + assertWithMessage("No MapView found in ${activity.javaClass.simpleName}") + .that(requested) + .isTrue() + assertWithMessage("Map in ${activity.javaClass.simpleName} was not ready within ${TIMEOUT_MS}ms") + .that(ready.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue() + } + + private fun findMapView(view: View): MapView? { + if (view is MapView) return view + if (view is ViewGroup) { + for (i in 0 until view.childCount) { + findMapView(view.getChildAt(i))?.let { return it } + } + } + return null + } + + companion object { + private const val TIMEOUT_MS = 15_000L + private const val POLL_MS = 100L + + /** Demos that show something other than a map. */ + private val DEMOS_WITHOUT_MAP: Set> = setOf( + StreetViewActivity::class.java, + ) + + @JvmStatic + @Parameterized.Parameters(name = "{2}") + fun demos(): List> = + allActivityGroups.flatMap { group -> + group.activities.map { demo -> arrayOf(group, demo, demo.kClass.java.simpleName) } + } + } +} diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt index ad943f91..421acc7d 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt @@ -47,6 +47,28 @@ class DemoRegistryTest { assertThat(undeclared.map { it.kClass.simpleName }).isEmpty() } + /** + * The reverse of [everyDemoIsDeclaredInTheManifest]: an activity declared in the manifest but + * missing from the menu builds cleanly and is simply unreachable, and no smoke test covers it. + */ + @Test + fun everyDeclaredActivityIsInTheMenu() { + val demoPackage = MainActivity::class.java.`package`!!.name + + @Suppress("DEPRECATION") + val declared = context.packageManager + .getPackageInfo(context.packageName, PackageManager.GET_ACTIVITIES) + .activities + .orEmpty() + .map { it.name } + .filter { it.startsWith("$demoPackage.") } + .toSet() - MainActivity::class.java.name + + val inMenu = demos.map { it.kClass.java.name }.toSet() + + assertThat(declared - inMenu).isEmpty() + } + /** * The demo list launches each entry with a bare [android.content.Intent], which only works * for activities the system will start from the sample's own task. diff --git a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt index 6f1bef82..b814f27d 100644 --- a/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt +++ b/maps-app/src/main/java/com/google/maps/android/compose/Demo.kt @@ -39,6 +39,7 @@ 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.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -235,6 +236,12 @@ val allActivityGroups = listOf( ActivityGroup.Performance, ) +/** Test tag for a group header in [DemoList], used by the instrumented smoke test. */ +internal fun groupTestTag(group: ActivityGroup): String = "group:${group.title}" + +/** Test tag for a demo card in [DemoList], used by the instrumented smoke test. */ +internal fun demoTestTag(activity: Activity): String = "demo:${activity.kClass.java.name}" + /** * A composable function that displays a collapsible list of demo activity groups. This is the * main UI component for the main screen. @@ -289,6 +296,7 @@ private fun DemoActivityItem( modifier = Modifier .fillMaxWidth() .padding(vertical = 4.dp) + .testTag(demoTestTag(activity)) .clickable { onActivityClick(activity.kClass) } ) { Column(modifier = Modifier.padding(16.dp)) { @@ -314,6 +322,7 @@ private fun GroupHeaderItem( modifier = Modifier .fillMaxWidth() .padding(8.dp) + .testTag(groupTestTag(group)) .clickable { onGroupClicked() } From f730f05f3772fe2fc858cf6d5975e3543f660d91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 13:40:42 +0200 Subject: [PATCH 6/7] test: run the demo smoke tests in their own workflow Move DemoAppSmokeTest, DemoMenuNavigationTest and DemoRegistryTest into a smoke package. A new Demo smoke test workflow runs only that package on API 36 with KVM and a real key, and the instrumentation workflow excludes it and goes back to its previous runner, API level and timeout, so the coverage history stays comparable. DemoAppSmokeTest now zooms a map that is fully on screen, which fixes a flaky wait in MapsInLazyColumnActivity. --- .github/workflows/demo-smoke-test.yml | 99 +++++++++++++++++++ .github/workflows/instrumentation-test.yml | 31 ++---- AGENTS.md | 16 +++ .../compose/{ => smoke}/DemoAppSmokeTest.kt | 18 +++- .../{ => smoke}/DemoMenuNavigationTest.kt | 8 +- .../compose/{ => smoke}/DemoRegistryTest.kt | 4 +- 6 files changed, 148 insertions(+), 28 deletions(-) create mode 100644 .github/workflows/demo-smoke-test.yml rename maps-app/src/androidTest/java/com/google/maps/android/compose/{ => smoke}/DemoAppSmokeTest.kt (91%) rename maps-app/src/androidTest/java/com/google/maps/android/compose/{ => smoke}/DemoMenuNavigationTest.kt (93%) rename maps-app/src/androidTest/java/com/google/maps/android/compose/{ => smoke}/DemoRegistryTest.kt (96%) diff --git a/.github/workflows/demo-smoke-test.yml b/.github/workflows/demo-smoke-test.yml new file mode 100644 index 00000000..24b0bc86 --- /dev/null +++ b/.github/workflows/demo-smoke-test.yml @@ -0,0 +1,99 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Opens every demo in maps-app on an emulator, directly and through the menu, and checks that it +# starts, its map loads, and it survives a zoom and a configuration change. The tests live in +# maps-app/src/androidTest/.../compose/smoke and are excluded from the instrumentation workflow. +name: Demo smoke test + +on: + repository_dispatch: + types: [test] + pull_request: + branches-ignore: ['gh-pages'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + demo-smoke-test: + # Ubuntu runners support KVM, which boots the API 36 emulator in about a minute. + runs-on: ubuntu-latest + timeout-minutes: 45 + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + # Secrets are not available to pull requests from forks or Dependabot. Without a real key + # the maps never load, so skip the emulator run instead of reporting noise. + - name: Check for Maps API key + id: key + env: + MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }} + run: | + if [ -z "$MAPS_API_KEY" ]; then + echo "::notice::ACTIONS_API_KEY is not available, skipping the demo smoke test." + echo "available=false" >> "$GITHUB_OUTPUT" + else + echo "MAPS_API_KEY=$MAPS_API_KEY" > secrets.properties + echo "available=true" >> "$GITHUB_OUTPUT" + fi + + - name: Set up JDK 21 + if: steps.key.outputs.available == 'true' + uses: actions/setup-java@b6effb05e454b25005698d916606bdc6ffcbf961 # v5.7.0 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + if: steps.key.outputs.available == 'true' + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + + - name: Enable KVM + if: steps.key.outputs.available == 'true' + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + - name: Build demo and test APKs + if: steps.key.outputs.available == 'true' + run: ./gradlew :maps-app:assembleDebug :maps-app:assembleDebugAndroidTest --stacktrace + + - name: Run demo smoke test + if: steps.key.outputs.available == 'true' + uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 + with: + # Emulators never update Google Play services, so the image's bundled version is what the + # maps run on. Older images bundle a version where adding advanced markers crashes inside + # Play services. API 36 images bundle 25.26, which works. + api-level: 36 + target: google_apis + arch: x86_64 + disable-animations: true + script: >- + ./gradlew :maps-app:connectedDebugAndroidTest --stacktrace + -Pandroid.testInstrumentationRunnerArguments.package=com.google.maps.android.compose.smoke + -Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true + + - name: Upload test reports + if: always() && steps.key.outputs.available == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: demo-smoke-test-reports + path: | + maps-app/build/reports/androidTests + maps-app/build/outputs/androidTest-results diff --git a/.github/workflows/instrumentation-test.yml b/.github/workflows/instrumentation-test.yml index d56388e2..34018e15 100644 --- a/.github/workflows/instrumentation-test.yml +++ b/.github/workflows/instrumentation-test.yml @@ -24,12 +24,10 @@ on: jobs: run-instrumentation-test: - # Ubuntu runners support KVM, which boots the API 36 emulator in about a minute. On - # macOS-latest-large it took over five minutes and the job hit its timeout. - runs-on: ubuntu-latest + runs-on: macOS-latest-large # enables hardware acceleration in the virtual machine permissions: pull-requests: write - timeout-minutes: 45 + timeout-minutes: 30 steps: - name: Checkout Repo uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -47,35 +45,20 @@ jobs: env: MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }} run: | - # Tiles only render with a real key, so only then does the demo smoke test wait for maps to load. - if [ -z "$MAPS_API_KEY" ]; then - MAPS_API_KEY="YOUR_API_KEY" - echo "REQUIRE_MAP_LOADED=false" >> "$GITHUB_ENV" - else - echo "REQUIRE_MAP_LOADED=true" >> "$GITHUB_ENV" - fi - echo "MAPS_API_KEY=$MAPS_API_KEY" >> ./secrets.properties + [ -z "$MAPS_API_KEY" ] && MAPS_API_KEY="YOUR_API_KEY"; echo "MAPS_API_KEY=$MAPS_API_KEY" >> ./secrets.properties - name: Build debug run: ./gradlew assembleDebug - - name: Enable KVM - run: | - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules - sudo udevadm control --reload-rules - sudo udevadm trigger --name-match=kvm - - name: Run instrumentation tests uses: reactivecircus/android-emulator-runner@a421e43855164a8197daf9d8d40fe71c6996bb0d # v2.38.0 with: - # Emulators never update Google Play services, so the image's bundled version is what the - # maps run on. Older images bundle Play services where adding advanced markers crashes - # inside Play services. API 36 images bundle 25.26, which works. - api-level: 36 + api-level: 29 target: google_apis - arch: x86_64 + arch: x86 disable-animations: true - script: ./gradlew createDebugCoverageReport --stacktrace -Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=$REQUIRE_MAP_LOADED + # The demo smoke tests run in their own workflow, see demo-smoke-test.yml. + script: ./gradlew createDebugCoverageReport --stacktrace -Pandroid.testInstrumentationRunnerArguments.notPackage=com.google.maps.android.compose.smoke # Stage the coverage reports flat, named after their module, and publish # them so the post-merge "Record coverage history" workflow can read them diff --git a/AGENTS.md b/AGENTS.md index 6128a730..8bce2ba5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -60,6 +60,22 @@ Note that the library modules currently have almost no unit tests. The bulk of the suite is instrumentation tests under `maps-app/src/androidTest`, so the `unit` numbers are near zero by nature rather than by regression. +The demo smoke tests in `maps-app/src/androidTest/.../compose/smoke` run in +their own `Demo smoke test` workflow on API 36, and are excluded from the +instrumentation workflow above, so they do not count towards instrumentation +coverage. `DemoAppSmokeTest` launches every demo directly, `DemoMenuNavigationTest` +opens each one from the menu and navigates back, and `DemoRegistryTest` keeps the +menu and the manifest in sync. Add new demos to `allActivityGroups` in `Demo.kt` +and they are covered automatically. To run them with a device or emulator attached: + +```bash +./gradlew :maps-app:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.package=com.google.maps.android.compose.smoke +``` + +Add `-Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true` to also +wait for map tiles to render, which needs a real key. + Running `maps-app` requires a Maps API key: put `MAPS_API_KEY=...` in `secrets.properties` at the repo root (see `local.defaults.properties` for the template). Never hardcode or commit API keys. diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoAppSmokeTest.kt similarity index 91% rename from maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt rename to maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoAppSmokeTest.kt index 92115d99..196f3abf 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoAppSmokeTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoAppSmokeTest.kt @@ -14,8 +14,9 @@ * limitations under the License. */ -package com.google.maps.android.compose +package com.google.maps.android.compose.smoke +import android.graphics.Rect import android.view.View import android.view.ViewGroup import androidx.activity.ComponentActivity @@ -28,6 +29,8 @@ import com.google.android.gms.maps.MapView import com.google.android.gms.maps.StreetViewPanoramaView import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertWithMessage +import com.google.maps.android.compose.allActivityGroups +import com.google.maps.android.compose.hasValidApiKey import org.junit.After import org.junit.Assume.assumeTrue import org.junit.Before @@ -200,11 +203,15 @@ class DemoAppSmokeTest( * tiles to render each time. The `GoogleMap` composable registers its loaded callback once, * so replacing it means a demo's own `onMapLoaded` stops firing for the rest of the test, * which only affects UI such as loading indicators. Demos showing Street View are skipped. + * + * A map that is fully on screen is preferred: in demos with several maps in a scrolling list, + * a map partly off screen may never report that it finished loading. */ private fun ActivityScenario.zoomOutAndBackIn() { var mapView: MapView? = null onActivity { activity -> - mapView = activity.window.decorView.mapSurfaces().filterIsInstance().firstOrNull() + val mapViews = activity.window.decorView.mapSurfaces().filterIsInstance() + mapView = mapViews.firstOrNull { it.isFullyOnScreen() } ?: mapViews.firstOrNull() } val view = mapView ?: return @@ -240,6 +247,13 @@ class DemoAppSmokeTest( throw AssertionError("$demoName crashed on a background thread", failure) } + /** True when this view is shown and its whole area is visible on screen. */ + private fun View.isFullyOnScreen(): Boolean { + if (!isShown || width == 0 || height == 0) return false + val visible = Rect() + return getGlobalVisibleRect(visible) && visible.width() == width && visible.height() == height + } + /** Depth-first walk collecting every Maps SDK surface below this view. */ private fun View.mapSurfaces(): List = buildList { fun walk(view: View) { diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoMenuNavigationTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoMenuNavigationTest.kt similarity index 93% rename from maps-app/src/androidTest/java/com/google/maps/android/compose/DemoMenuNavigationTest.kt rename to maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoMenuNavigationTest.kt index dc9fabc6..0999d4c9 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoMenuNavigationTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoMenuNavigationTest.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package com.google.maps.android.compose +package com.google.maps.android.compose.smoke import android.app.Activity import android.os.SystemClock @@ -30,6 +30,12 @@ import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage import com.google.android.gms.maps.MapView import com.google.common.truth.Truth.assertWithMessage +import com.google.maps.android.compose.ActivityGroup +import com.google.maps.android.compose.MainActivity +import com.google.maps.android.compose.StreetViewActivity +import com.google.maps.android.compose.allActivityGroups +import com.google.maps.android.compose.demoTestTag +import com.google.maps.android.compose.groupTestTag import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoRegistryTest.kt similarity index 96% rename from maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt rename to maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoRegistryTest.kt index 421acc7d..6970a3af 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/DemoRegistryTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoRegistryTest.kt @@ -14,12 +14,14 @@ * limitations under the License. */ -package com.google.maps.android.compose +package com.google.maps.android.compose.smoke import android.content.ComponentName import android.content.pm.PackageManager import androidx.test.platform.app.InstrumentationRegistry import com.google.common.truth.Truth.assertThat +import com.google.maps.android.compose.MainActivity +import com.google.maps.android.compose.allActivityGroups import org.junit.Test /** From c18995584467830d873958849f7820383852e60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20Lo=CC=81pez=20Man=CC=83as?= Date: Thu, 1 Oct 2026 19:25:49 +0200 Subject: [PATCH 7/7] test: address review feedback on the demo smoke tests - DemoMenuNavigationTest scrolls the menu to each group and demo before clicking, since items below the fold are not composed until the LazyColumn scrolls to them. - It covers StreetViewActivity through its StreetViewPanoramaView instead of exempting it. The wait advances the compose test clock, which drives every composition in the process: the panorama only appears after a recomposition that follows an async metadata request. Without a valid key, that demo is only checked for opening. - Both tests record background-thread crashes and check for them in @After as well, to catch crashes during teardown. tearDown only restores the default handler it replaced. --- .../android/compose/smoke/DemoAppSmokeTest.kt | 13 ++- .../compose/smoke/DemoMenuNavigationTest.kt | 89 +++++++++++++++---- 2 files changed, 81 insertions(+), 21 deletions(-) diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoAppSmokeTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoAppSmokeTest.kt index 196f3abf..8ed7d35c 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoAppSmokeTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoAppSmokeTest.kt @@ -124,9 +124,16 @@ class DemoAppSmokeTest( } } + /** Also catches crashes while the demo is torn down after the test body. */ @After fun tearDown() { - Thread.setDefaultUncaughtExceptionHandler(defaultHandler) + try { + assertNoUncaughtExceptions() + } finally { + // Only restore what setUp replaced: if the key check skipped the test, defaultHandler + // was never read, and restoring null would disable crash reporting for later tests. + defaultHandler?.let { Thread.setDefaultUncaughtExceptionHandler(it) } + } } @Test @@ -251,7 +258,9 @@ class DemoAppSmokeTest( private fun View.isFullyOnScreen(): Boolean { if (!isShown || width == 0 || height == 0) return false val visible = Rect() - return getGlobalVisibleRect(visible) && visible.width() == width && visible.height() == height + return getGlobalVisibleRect(visible) && + visible.width() == width && + visible.height() == height } /** Depth-first walk collecting every Maps SDK surface below this view. */ diff --git a/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoMenuNavigationTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoMenuNavigationTest.kt index 0999d4c9..24191847 100644 --- a/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoMenuNavigationTest.kt +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoMenuNavigationTest.kt @@ -20,15 +20,18 @@ import android.app.Activity import android.os.SystemClock import android.view.View import android.view.ViewGroup +import androidx.compose.ui.test.hasScrollToIndexAction +import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick -import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performScrollToNode import androidx.test.espresso.Espresso import androidx.test.platform.app.InstrumentationRegistry import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage import com.google.android.gms.maps.MapView +import com.google.android.gms.maps.StreetViewPanoramaView import com.google.common.truth.Truth.assertWithMessage import com.google.maps.android.compose.ActivityGroup import com.google.maps.android.compose.MainActivity @@ -36,17 +39,22 @@ import com.google.maps.android.compose.StreetViewActivity import com.google.maps.android.compose.allActivityGroups import com.google.maps.android.compose.demoTestTag import com.google.maps.android.compose.groupTestTag +import com.google.maps.android.compose.hasValidApiKey +import org.junit.After +import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.junit.runners.Parameterized +import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CountDownLatch import java.util.concurrent.TimeUnit import com.google.maps.android.compose.Activity as Demo /** * Walks the sample app the way a user does: starts [MainActivity], expands the demo's group, - * taps the demo, waits for its map, and presses back to return to the menu. + * taps the demo, waits for its map or Street View panorama, and presses back to return to the + * menu. A crash on a background thread at any point fails the test. * * [DemoAppSmokeTest] launches each demo directly to check it in depth. This test covers the path * a reviewer actually takes, so a broken menu entry, click handler or back navigation fails here. @@ -63,17 +71,47 @@ class DemoMenuNavigationTest( private val instrumentation = InstrumentationRegistry.getInstrumentation() + private val uncaughtExceptions = CopyOnWriteArrayList() + private var defaultHandler: Thread.UncaughtExceptionHandler? = null + + @Before + fun setUp() { + defaultHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { _, throwable -> + uncaughtExceptions.add(throwable) + } + } + + /** Also catches crashes while the demo is torn down after the test body. */ + @After + fun tearDown() { + try { + assertNoUncaughtExceptions() + } finally { + defaultHandler?.let { Thread.setDefaultUncaughtExceptionHandler(it) } + } + } + @Test fun opensFromMenuAndNavigatesBack() { val demoClass = demo.kClass.java + // Scroll the list to each node first: groups and demos below the fold, on small screens + // or in landscape, are not composed until the LazyColumn scrolls to them. + val menu = composeRule.onNode(hasScrollToIndexAction()) + menu.performScrollToNode(hasTestTag(groupTestTag(group))) composeRule.onNodeWithTag(groupTestTag(group)).performClick() - composeRule.onNodeWithTag(demoTestTag(demo)).performScrollTo().performClick() + menu.performScrollToNode(hasTestTag(demoTestTag(demo))) + composeRule.onNodeWithTag(demoTestTag(demo)).performClick() val activity = waitForResumedActivity(demoClass) - if (demoClass !in DEMOS_WITHOUT_MAP) { - awaitMapReady(activity) + // Maps become ready without a valid key, but StreetViewActivity only shows its panorama + // after a Street View metadata request succeeds, which needs one. Without a key that demo + // is only checked for opening, so the test still runs on forks and locally. + if (hasValidApiKey || demoClass != StreetViewActivity::class.java) { + awaitMapSurfaceReady(activity) } + assertNoUncaughtExceptions() assertWithMessage("${demoClass.simpleName} finished by itself") .that(activity.isFinishing) @@ -109,46 +147,59 @@ class DemoMenuNavigationTest( ) } - /** Finds the first [MapView] in [activity] and waits for its map to be ready. */ - private fun awaitMapReady(activity: Activity) { + /** + * Finds the first [MapView] or [StreetViewPanoramaView] in [activity] and waits for its map or + * panorama to be ready. + */ + private fun awaitMapSurfaceReady(activity: Activity) { val deadline = SystemClock.uptimeMillis() + TIMEOUT_MS val ready = CountDownLatch(1) var requested = false while (!requested && SystemClock.uptimeMillis() < deadline) { + // The compose rule drives the frame clock of every composition in the process, so + // advance it: StreetViewActivity only shows its panorama after a recomposition that + // follows an async metadata request. + composeRule.mainClock.advanceTimeByFrame() instrumentation.runOnMainSync { - val mapView = findMapView(activity.window.decorView) ?: return@runOnMainSync - mapView.getMapAsync { ready.countDown() } + when (val surface = findMapSurface(activity.window.decorView)) { + is MapView -> surface.getMapAsync { ready.countDown() } + is StreetViewPanoramaView -> + surface.getStreetViewPanoramaAsync { ready.countDown() } + else -> return@runOnMainSync + } requested = true } if (!requested) SystemClock.sleep(POLL_MS) } - assertWithMessage("No MapView found in ${activity.javaClass.simpleName}") + val name = activity.javaClass.simpleName + assertWithMessage("No MapView or StreetViewPanoramaView found in $name") .that(requested) .isTrue() - assertWithMessage("Map in ${activity.javaClass.simpleName} was not ready within ${TIMEOUT_MS}ms") + assertWithMessage("Map surface in $name was not ready within ${TIMEOUT_MS}ms") .that(ready.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) .isTrue() } - private fun findMapView(view: View): MapView? { - if (view is MapView) return view + private fun findMapSurface(view: View): View? { + if (view is MapView || view is StreetViewPanoramaView) return view if (view is ViewGroup) { for (i in 0 until view.childCount) { - findMapView(view.getChildAt(i))?.let { return it } + findMapSurface(view.getChildAt(i))?.let { return it } } } return null } + private fun assertNoUncaughtExceptions() { + val failure = uncaughtExceptions.firstOrNull() ?: return + val name = demo.kClass.java.simpleName + throw AssertionError("$name crashed on a background thread", failure) + } + companion object { private const val TIMEOUT_MS = 15_000L private const val POLL_MS = 100L - /** Demos that show something other than a map. */ - private val DEMOS_WITHOUT_MAP: Set> = setOf( - StreetViewActivity::class.java, - ) - @JvmStatic @Parameterized.Parameters(name = "{2}") fun demos(): List> =