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() + } +}