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 9bb4318f..34018e15 100644 --- a/.github/workflows/instrumentation-test.yml +++ b/.github/workflows/instrumentation-test.yml @@ -57,7 +57,8 @@ jobs: target: google_apis arch: x86 disable-animations: true - script: ./gradlew createDebugCoverageReport --stacktrace + # 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/smoke/DemoAppSmokeTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoAppSmokeTest.kt new file mode 100644 index 00000000..8ed7d35c --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoAppSmokeTest.kt @@ -0,0 +1,276 @@ +/* + * 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.smoke + +import android.graphics.Rect +import android.view.View +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 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 +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. + * + * 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, + * - 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 + * 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 + + /** 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. + */ + 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, + ) + } + } + + /** + * 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 + + @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) + } + } + + /** Also catches crashes while the demo is torn down after the test body. */ + @After + fun tearDown() { + 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 + fun demoLaunchesAndShowsMap() { + ActivityScenario.launch(demoActivity).use { scenario -> + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP_SURFACE) { + scenario.awaitMapSurface() + scenario.zoomOutAndBackIn() + } + scenario.assertResumed() + 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", + ) + } + + /** + * 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. + * + * 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 -> + val mapViews = activity.window.decorView.mapSurfaces().filterIsInstance() + mapView = mapViews.firstOrNull { it.isFullyOnScreen() } ?: mapViews.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) + } + + /** 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) { + 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/smoke/DemoMenuNavigationTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoMenuNavigationTest.kt new file mode 100644 index 00000000..24191847 --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoMenuNavigationTest.kt @@ -0,0 +1,210 @@ +/* + * 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.smoke + +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.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 +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 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. + * 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() + + 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() + menu.performScrollToNode(hasTestTag(demoTestTag(demo))) + composeRule.onNodeWithTag(demoTestTag(demo)).performClick() + + val activity = waitForResumedActivity(demoClass) + // 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) + .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] 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 { + 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) + } + val name = activity.javaClass.simpleName + assertWithMessage("No MapView or StreetViewPanoramaView found in $name") + .that(requested) + .isTrue() + assertWithMessage("Map surface in $name was not ready within ${TIMEOUT_MS}ms") + .that(ready.await(TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue() + } + + 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) { + 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 + + @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/smoke/DemoRegistryTest.kt b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoRegistryTest.kt new file mode 100644 index 00000000..6970a3af --- /dev/null +++ b/maps-app/src/androidTest/java/com/google/maps/android/compose/smoke/DemoRegistryTest.kt @@ -0,0 +1,110 @@ +/* + * 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.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 + +/** + * 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 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. + */ + @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() + } +} 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() }