diff --git a/.github/workflows/demo-smoke-test.yml b/.github/workflows/demo-smoke-test.yml new file mode 100644 index 000000000..83d6d616d --- /dev/null +++ b/.github/workflows/demo-smoke-test.yml @@ -0,0 +1,120 @@ +# 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 ApiDemos demo on an emulator and checks that it starts, its map or panorama loads, +# and it survives a zoom and a configuration change. The tests live in the `smoke` package of each +# app's androidTest sources and are excluded from the instrumentation workflow. +# +# Like the instrumentation workflow, this needs the ACTIONS_API_KEY secret and is skipped +# without it. +name: Demo smoke test + +on: + pull_request: + branches-ignore: ['gh-pages'] + workflow_dispatch: + +permissions: + contents: read + +jobs: + # secrets cannot be referenced from a job-level `if`, so the presence of the + # key is resolved here and published as an output the emulator job gates on. + check-api-key: + runs-on: ubuntu-latest + outputs: + has-key: ${{ steps.check.outputs.has-key }} + steps: + - name: Check for Maps API key + id: check + env: + MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }} + run: | + if [ -n "$MAPS_API_KEY" ]; then + echo "has-key=true" >> "$GITHUB_OUTPUT" + else + echo "has-key=false" >> "$GITHUB_OUTPUT" + echo "::notice::ACTIONS_API_KEY is not set; skipping the demo smoke test." + fi + + demo-smoke-test: + needs: check-api-key + if: needs.check-api-key.outputs.has-key == 'true' + runs-on: ubuntu-latest + timeout-minutes: 60 + strategy: + fail-fast: false + matrix: + include: + - module: ':ApiDemos:java-app' + path: ApiDemos/project/java-app + smoke-package: com.example.mapdemo.smoke + - module: ':ApiDemos:kotlin-app' + path: ApiDemos/project/kotlin-app + smoke-package: com.example.kotlindemos.smoke + + steps: + - name: Checkout Repo + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Set up JDK 21 + uses: actions/setup-java@de7274f081f381c8f8158605e0321c36c376e2e6 # v6.0.1 + with: + java-version: '21' + distribution: 'temurin' + + - name: Setup Gradle + uses: gradle/actions/setup-gradle@9c971963bec38e04b3d30dcc455b5382be2fdbfb # v6.3.0 + + - 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: Inject Maps API key + env: + MAPS_API_KEY: ${{ secrets.ACTIONS_API_KEY }} + run: echo "MAPS_API_KEY=$MAPS_API_KEY" >> ./secrets.properties + + - name: Build demo and test APKs + run: ./gradlew ${{ matrix.module }}:assembleDebug ${{ matrix.module }}:assembleDebugAndroidTest --stacktrace + + - name: Run demo smoke test + 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 ${{ matrix.module }}:connectedDebugAndroidTest --stacktrace + -Pandroid.testInstrumentationRunnerArguments.package=${{ matrix.smoke-package }} + -Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: demo-smoke-test-reports-${{ strategy.job-index }} + path: | + ${{ matrix.path }}/build/reports/androidTests + ${{ matrix.path }}/build/outputs/androidTest-results + if-no-files-found: warn diff --git a/.github/workflows/instrumentation-test.yml b/.github/workflows/instrumentation-test.yml index 386228cdb..fdcf1cd45 100644 --- a/.github/workflows/instrumentation-test.yml +++ b/.github/workflows/instrumentation-test.yml @@ -60,9 +60,11 @@ jobs: strategy: fail-fast: false matrix: - module: - - ':ApiDemos:java-app' - - ':ApiDemos:kotlin-app' + include: + - module: ':ApiDemos:java-app' + smoke-package: com.example.mapdemo.smoke + - module: ':ApiDemos:kotlin-app' + smoke-package: com.example.kotlindemos.smoke steps: - name: Checkout Repo @@ -100,7 +102,10 @@ jobs: target: google_apis arch: x86 disable-animations: true - script: ./gradlew ${{ matrix.module }}:connectedDebugAndroidTest --stacktrace + # The demo smoke tests run in their own workflow on a newer emulator image. + script: >- + ./gradlew ${{ matrix.module }}:connectedDebugAndroidTest --stacktrace + -Pandroid.testInstrumentationRunnerArguments.notPackage=${{ matrix.smoke-package }} - name: Upload test reports if: always() diff --git a/AGENTS.md b/AGENTS.md index 54b338c5f..f5b07ce14 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -56,6 +56,17 @@ demonstrate the same features and must stay in sync. python3 scripts/update_docs_versions.py --check # verify doc snippet versions match version catalog ``` +- **ApiDemos smoke test** (emulator, needs a real API key): opens every demo activity and checks + that its map loads and that it survives a zoom and a configuration change. New demos are picked + up from the manifest automatically. + ```bash + ./gradlew :ApiDemos:kotlin-app:connectedDebugAndroidTest \ + -Pandroid.testInstrumentationRunnerArguments.package=com.example.kotlindemos.smoke \ + -Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true + ``` + For the Java app use `:ApiDemos:java-app` and `com.example.mapdemo.smoke`. Demos that need a + map ID are skipped unless `MAP_ID` is set. + Snippet modules do not contain unit tests; they are verified through successful compilation (`assembleDebug`), Android lint (`lintDebug`), and doc version synchronization. Running the apps requires a Maps API key: copy the keys named in diff --git a/ApiDemos/project/java-app/build.gradle.kts b/ApiDemos/project/java-app/build.gradle.kts index 096a2764f..292a13fd8 100644 --- a/ApiDemos/project/java-app/build.gradle.kts +++ b/ApiDemos/project/java-app/build.gradle.kts @@ -31,6 +31,8 @@ android { versionName = libs.versions.versionName.get() multiDexEnabled = true testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Start every instrumented test from a clean app state. + testInstrumentationRunnerArguments["clearPackageData"] = "true" } buildFeatures { @@ -55,6 +57,11 @@ android { namespace = "com.example.mapdemo" + testOptions { + // Each test runs in its own process, so a crashing demo does not abort the rest of the run. + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + lint { disable += setOf("MissingInflatedId") sarifOutput = layout.buildDirectory.file("reports/lint-results-debug.sarif").get().asFile @@ -79,6 +86,8 @@ dependencies { androidTestImplementation(libs.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.truth) + androidTestImplementation(libs.androidx.test.rules) + androidTestUtil(libs.androidx.test.orchestrator) androidTestImplementation(libs.ext.junit) } diff --git a/ApiDemos/project/java-app/src/androidTest/java/com/example/mapdemo/smoke/DemoSmokeTest.java b/ApiDemos/project/java-app/src/androidTest/java/com/example/mapdemo/smoke/DemoSmokeTest.java new file mode 100644 index 000000000..9437deb19 --- /dev/null +++ b/ApiDemos/project/java-app/src/androidTest/java/com/example/mapdemo/smoke/DemoSmokeTest.java @@ -0,0 +1,451 @@ +// 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.example.mapdemo.smoke; + +import static com.google.common.truth.Truth.assertThat; +import static com.google.common.truth.Truth.assertWithMessage; +import static org.junit.Assume.assumeTrue; + +import android.Manifest; +import android.app.Activity; +import android.content.Context; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.graphics.Rect; +import android.os.Looper; +import android.view.View; +import android.view.ViewGroup; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.Lifecycle; +import androidx.test.core.app.ActivityScenario; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.GrantPermissionRule; +import androidx.viewpager.widget.ViewPager; +import com.example.common_ui.R; +import com.example.mapdemo.BuildConfig; +import com.example.mapdemo.MainActivity; +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.android.gms.maps.SupportMapFragment; +import com.google.android.gms.maps.SupportStreetViewPanoramaFragment; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; +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; + +/** + * End-to-end smoke test covering every demo in the app. + * + *

The parameter list is every activity the manifest declares in the app's package except + * {@link MainActivity}, so a demo added to the app is covered here with no change to this file. + * + *

Each demo is checked for these things: + *

+ * + *

This deliberately does not assert on map content. Checking a particular marker or overlay + * belongs in the focused tests next to this one; the value here is breadth. + */ +@RunWith(Parameterized.class) +public class DemoSmokeTest { + + /** + * How long to wait for a demo's map to be laid out and ready. Generous because the first demo + * to run on a cold emulator pays for Maps SDK initialization. + */ + private static final long MAP_READY_TIMEOUT_MS = 20_000L; + + private static final long POLL_INTERVAL_MS = 250L; + + /** How long to wait for tiles to render when {@code requireMapLoaded} is set. */ + private static final long MAP_LOADED_TIMEOUT_MS = 30_000L; + + /** Time given to a demo to react to a camera change before the next check. */ + private static final long SETTLE_MS = 1_500L; + + /** + * Demos that legitimately show no map 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 static final Set DEMOS_WITHOUT_MAP = Collections.emptySet(); + + /** Demos that close themselves when no map ID is configured, see the README. */ + private static final Set DEMOS_REQUIRING_MAP_ID = new HashSet<>(Arrays.asList( + "AdvancedMarkersDemoActivity", + "DataDrivenBoundariesActivity", + "DataDrivenDatasetStylingActivity")); + + @Parameterized.Parameters(name = "{0}") + public static List demos() throws Exception { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + PackageInfo packageInfo = context.getPackageManager() + .getPackageInfo(context.getPackageName(), PackageManager.GET_ACTIVITIES); + List names = new ArrayList<>(); + if (packageInfo.activities != null) { + for (ActivityInfo activity : packageInfo.activities) { + if (activity.name.startsWith(context.getPackageName() + ".") + && !activity.name.equals(MainActivity.class.getName())) { + names.add(activity.name); + } + } + } + Collections.sort(names); + + List demos = new ArrayList<>(); + for (String name : names) { + demos.add(new Object[] { + name.substring(name.lastIndexOf('.') + 1), + Class.forName(name).asSubclass(Activity.class), + }); + } + return demos; + } + + /** The location demos ask for this on start; a permission dialog would cover the map. */ + @Rule + public final GrantPermissionRule permissions = GrantPermissionRule.grant( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION); + + private final String demoName; + private final Class demoActivity; + + /** + * Tiles only render with a real key, so waiting for them is opt-in: pass + * {@code -Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true}. + */ + private final boolean requireMapLoaded = Boolean.parseBoolean( + InstrumentationRegistry.getArguments().getString("requireMapLoaded")); + + private final List uncaughtExceptions = new CopyOnWriteArrayList<>(); + private Thread.UncaughtExceptionHandler defaultHandler; + + public DemoSmokeTest(String demoName, Class demoActivity) { + this.demoName = demoName; + this.demoActivity = demoActivity; + } + + @Before + public void setUp() { + if (DEMOS_REQUIRING_MAP_ID.contains(demoName)) { + assumeTrue("Map ID not specified", hasMapId()); + } + + Thread.UncaughtExceptionHandler handler = Thread.getDefaultUncaughtExceptionHandler(); + defaultHandler = handler; + Thread.setDefaultUncaughtExceptionHandler((thread, throwable) -> { + uncaughtExceptions.add(throwable); + // The main looper cannot recover from a crash, so let it take the process down: the + // orchestrator reports it against this test and carries on with the next one. + if (thread == Looper.getMainLooper().getThread() && handler != null) { + handler.uncaughtException(thread, throwable); + } + }); + } + + /** Also catches crashes while the demo is torn down after the test body. */ + @After + public void tearDown() { + try { + assertNoUncaughtExceptions(); + } finally { + // Only restore what setUp replaced: if the map ID check skipped the test, restoring + // null would disable crash reporting for later tests. + if (defaultHandler != null) { + Thread.setDefaultUncaughtExceptionHandler(defaultHandler); + } + } + } + + /** Mirrors ApiDemoApplication.getMapId(), which cannot be called here as it shows a toast. */ + private static boolean hasMapId() { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + return !BuildConfig.MAP_ID.equals("MAP_ID") + || !context.getString(R.string.map_id).equals("DEMO_MAP_ID"); + } + + @Test + public void demoLaunchesAndShowsMap() throws InterruptedException { + try (ActivityScenario scenario = ActivityScenario.launch(demoActivity)) { + assertResumed(scenario); + if (!DEMOS_WITHOUT_MAP.contains(demoName)) { + GoogleMap map = awaitMapSurfaceReady(scenario); + if (map != null) { + zoomOutAndBackIn(scenario, map); + } + } + assertResumed(scenario); + assertNoUncaughtExceptions(); + } + } + + /** + * Rotation and other configuration changes are where saved state and retained fragments tend + * to regress, and they are easy to miss when clicking through the app by hand. + */ + @Test + public void demoSurvivesConfigurationChange() throws InterruptedException { + try (ActivityScenario scenario = ActivityScenario.launch(demoActivity)) { + assertResumed(scenario); + if (!DEMOS_WITHOUT_MAP.contains(demoName)) { + awaitMapSurfaceReady(scenario); + } + + scenario.recreate(); + + assertResumed(scenario); + if (!DEMOS_WITHOUT_MAP.contains(demoName)) { + awaitMapSurfaceReady(scenario); + } + assertNoUncaughtExceptions(); + } + } + + private static void assertResumed(ActivityScenario scenario) { + assertThat(scenario.getState()).isEqualTo(Lifecycle.State.RESUMED); + } + + /** + * Waits until the demo shows a map or panorama that is laid out on screen, then until the + * Maps SDK reports it ready. Returns the map to zoom, or null when the demo only shows Street + * View. A map fully on screen is preferred: in a scrolling list of maps, one partly off screen + * may never report that it finished loading. + */ + @Nullable + private GoogleMap awaitMapSurfaceReady(ActivityScenario scenario) + throws InterruptedException { + long deadline = System.currentTimeMillis() + MAP_READY_TIMEOUT_MS; + List surfaces = new ArrayList<>(); + List shown = new ArrayList<>(); + + while (shown.isEmpty() && System.currentTimeMillis() < deadline) { + scenario.onActivity(activity -> { + surfaces.clear(); + surfaces.addAll(mapSurfaces(activity)); + shown.clear(); + for (MapSurface surface : surfaces) { + if (surface.view != null && isLaidOutOnScreen(surface.view)) { + shown.add(surface); + } + } + // Demos such as MapInPager open on a page without a map: swipe on to find it. + if (shown.isEmpty()) { + turnPagerPage(activity.getWindow().getDecorView()); + } + }); + if (shown.isEmpty()) { + Thread.sleep(POLL_INTERVAL_MS); + } + } + if (shown.isEmpty()) { + StringBuilder detail = new StringBuilder(); + if (surfaces.isEmpty()) { + detail.append("no map or Street View panorama was found"); + } else { + detail.append("found but not laid out:"); + for (MapSurface surface : surfaces) { + detail.append(' ').append(surface.name); + } + } + throw new AssertionError(demoName + " did not show a map within " + + MAP_READY_TIMEOUT_MS + "ms: " + detail); + } + + MapSurface surface = pickSurface(shown); + AtomicReference map = new AtomicReference<>(); + CountDownLatch ready = new CountDownLatch(1); + scenario.onActivity(activity -> surface.awaitReady.accept(readyMap -> { + map.set(readyMap); + ready.countDown(); + })); + assertWithMessage(demoName + " " + surface.name + " was not ready within " + + MAP_READY_TIMEOUT_MS + "ms") + .that(ready.await(MAP_READY_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue(); + return map.get(); + } + + private static MapSurface pickSurface(List shown) { + for (MapSurface surface : shown) { + if (surface.isMap && isFullyOnScreen(surface.view)) { + return surface; + } + } + for (MapSurface surface : shown) { + if (surface.isMap) { + return surface; + } + } + return shown.get(0); + } + + /** + * Zooms the map out and back in. When {@link #requireMapLoaded} is set, also waits for the + * tiles to render each time. This replaces the demo's own map loaded callback, if it had one, + * for the rest of the test. + */ + private void zoomOutAndBackIn(ActivityScenario scenario, GoogleMap map) + throws InterruptedException { + for (float delta : new float[] {-1f, 1f}) { + CountDownLatch loaded = new CountDownLatch(1); + scenario.onActivity(activity -> { + 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 void assertNoUncaughtExceptions() { + if (!uncaughtExceptions.isEmpty()) { + throw new AssertionError(demoName + " crashed on a background thread", + uncaughtExceptions.get(0)); + } + } + + /** + * A map or panorama shown by a demo, either as a view or as a fragment. {@link #awaitReady} + * calls back with the {@link GoogleMap} for a map, or with null for a panorama once it is + * ready. + */ + private static final class MapSurface { + final String name; + @Nullable final View view; + final boolean isMap; + final Consumer> awaitReady; + + MapSurface(String name, @Nullable View view, boolean isMap, + Consumer> awaitReady) { + this.name = name; + this.view = view; + this.isMap = isMap; + this.awaitReady = awaitReady; + } + } + + /** Every map and panorama in the activity, from its views and its fragments. */ + private static List mapSurfaces(Activity activity) { + List surfaces = new ArrayList<>(); + walk(activity.getWindow().getDecorView(), surfaces); + if (activity instanceof FragmentActivity) { + for (Fragment fragment + : ((FragmentActivity) activity).getSupportFragmentManager().getFragments()) { + walk(fragment, surfaces); + } + } + return surfaces; + } + + private static void walk(View view, List surfaces) { + if (view instanceof MapView) { + MapView mapView = (MapView) view; + surfaces.add(new MapSurface("MapView", view, true, + callback -> mapView.getMapAsync(callback::accept))); + } else if (view instanceof StreetViewPanoramaView) { + StreetViewPanoramaView panoramaView = (StreetViewPanoramaView) view; + surfaces.add(new MapSurface("StreetViewPanoramaView", view, false, + callback -> panoramaView.getStreetViewPanoramaAsync( + panorama -> callback.accept(null)))); + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int index = 0; index < group.getChildCount(); index++) { + walk(group.getChildAt(index), surfaces); + } + } + } + + private static void walk(Fragment fragment, List surfaces) { + if (fragment instanceof SupportMapFragment) { + SupportMapFragment mapFragment = (SupportMapFragment) fragment; + surfaces.add(new MapSurface("SupportMapFragment", fragment.getView(), true, + callback -> mapFragment.getMapAsync(callback::accept))); + } else if (fragment instanceof SupportStreetViewPanoramaFragment) { + SupportStreetViewPanoramaFragment panoramaFragment = + (SupportStreetViewPanoramaFragment) fragment; + surfaces.add(new MapSurface("SupportStreetViewPanoramaFragment", fragment.getView(), + false, callback -> panoramaFragment.getStreetViewPanoramaAsync( + panorama -> callback.accept(null)))); + } + for (Fragment child : fragment.getChildFragmentManager().getFragments()) { + walk(child, surfaces); + } + } + + /** Moves the first pager below this view that has a page left to its next page. */ + private static boolean turnPagerPage(View view) { + if (view instanceof ViewPager) { + ViewPager pager = (ViewPager) view; + int pageCount = pager.getAdapter() == null ? 0 : pager.getAdapter().getCount(); + if (pager.getCurrentItem() < pageCount - 1) { + pager.setCurrentItem(pager.getCurrentItem() + 1); + return true; + } + } + if (view instanceof ViewGroup) { + ViewGroup group = (ViewGroup) view; + for (int index = 0; index < group.getChildCount(); index++) { + if (turnPagerPage(group.getChildAt(index))) { + return true; + } + } + } + return false; + } + + private static boolean isLaidOutOnScreen(View view) { + return view.isShown() && view.getWidth() > 0 && view.getHeight() > 0; + } + + /** True when the view is shown and its whole area is visible on screen. */ + private static boolean isFullyOnScreen(View view) { + if (!isLaidOutOnScreen(view)) { + return false; + } + Rect visible = new Rect(); + return view.getGlobalVisibleRect(visible) + && visible.width() == view.getWidth() + && visible.height() == view.getHeight(); + } +} diff --git a/ApiDemos/project/kotlin-app/build.gradle.kts b/ApiDemos/project/kotlin-app/build.gradle.kts index 70a9c1dad..3e50885fc 100644 --- a/ApiDemos/project/kotlin-app/build.gradle.kts +++ b/ApiDemos/project/kotlin-app/build.gradle.kts @@ -32,6 +32,8 @@ android { versionCode = 1 versionName = libs.versions.versionName.get() testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + // Start every instrumented test from a clean app state. + testInstrumentationRunnerArguments["clearPackageData"] = "true" multiDexEnabled = true } @@ -51,6 +53,11 @@ android { } } + testOptions { + // Each test runs in its own process, so a crashing demo does not abort the rest of the run. + execution = "ANDROIDX_TEST_ORCHESTRATOR" + } + lint { disable += setOf("MissingInflatedId") sarifOutput = layout.buildDirectory.file("reports/lint-results-debug.sarif").get().asFile @@ -98,6 +105,8 @@ dependencies { androidTestImplementation(libs.junit) androidTestImplementation(libs.espresso.core) androidTestImplementation(libs.truth) + androidTestImplementation(libs.androidx.test.rules) + androidTestUtil(libs.androidx.test.orchestrator) implementation(project(":ApiDemos:common-ui")) } diff --git a/ApiDemos/project/kotlin-app/src/androidTest/java/com/example/kotlindemos/smoke/DemoSmokeTest.kt b/ApiDemos/project/kotlin-app/src/androidTest/java/com/example/kotlindemos/smoke/DemoSmokeTest.kt new file mode 100644 index 000000000..18a58a537 --- /dev/null +++ b/ApiDemos/project/kotlin-app/src/androidTest/java/com/example/kotlindemos/smoke/DemoSmokeTest.kt @@ -0,0 +1,360 @@ +// 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.example.kotlindemos.smoke + +import android.Manifest +import android.app.Activity +import android.content.pm.PackageManager +import android.graphics.Rect +import android.os.Looper +import android.view.View +import android.view.ViewGroup +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ActivityScenario +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.rule.GrantPermissionRule +import androidx.viewpager.widget.ViewPager +import com.example.common_ui.R +import com.example.kotlindemos.BuildConfig +import com.example.kotlindemos.MainActivity +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.OnMapReadyCallback +import com.google.android.gms.maps.StreetViewPanoramaView +import com.google.android.gms.maps.SupportMapFragment +import com.google.android.gms.maps.SupportStreetViewPanoramaFragment +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 +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 + +/** + * End-to-end smoke test covering every demo in the app. + * + * The parameter list is every activity the manifest declares in the app's package except + * [MainActivity], so a demo added to the app is covered here with no change to this file. + * + * Each demo is checked for these things: + * - it reaches [Lifecycle.State.RESUMED] without throwing, + * - a map or Street View panorama is attached, laid out and ready, + * - its map survives a zoom out and back in, + * - it survives a configuration change, + * - nothing crashes on a background thread while it is open. + * + * This deliberately does not assert on map content. Checking a particular marker or overlay + * belongs in the focused tests next to this one; the value here is breadth. + */ +@RunWith(Parameterized::class) +class DemoSmokeTest( + private val demoName: String, + private val demoActivity: Class, +) { + + companion object { + /** + * How long to wait for a demo's map to be laid out and ready. Generous because the first + * demo to run on a cold emulator pays for Maps SDK initialization. + */ + private const val MAP_READY_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 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 = emptySet() + + /** Demos that close themselves when no map ID is configured, see the README. */ + private val DEMOS_REQUIRING_MAP_ID = setOf( + "AdvancedMarkersDemoActivity", + "DataDrivenBoundariesActivity", + "DataDrivenDatasetStylingActivity", + ) + + @JvmStatic + @Parameterized.Parameters(name = "{0}") + fun demos(): List> { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val packageInfo = context.packageManager.getPackageInfo( + context.packageName, + PackageManager.GET_ACTIVITIES, + ) + return packageInfo.activities.orEmpty() + .map { it.name } + .filter { it.startsWith("${context.packageName}.") } + .filter { it != MainActivity::class.java.name } + .sorted() + .map { name -> + arrayOf( + name.substringAfterLast('.'), + Class.forName(name).asSubclass(Activity::class.java), + ) + } + } + } + + /** The location demos ask for this on start; a permission dialog would cover the map. */ + @get:Rule + val permissions: GrantPermissionRule = GrantPermissionRule.grant( + Manifest.permission.ACCESS_FINE_LOCATION, + Manifest.permission.ACCESS_COARSE_LOCATION, + ) + + /** + * Tiles only render with a real key, so waiting for them is opt-in: pass + * `-Pandroid.testInstrumentationRunnerArguments.requireMapLoaded=true`. + */ + private val requireMapLoaded = + InstrumentationRegistry.getArguments().getString("requireMapLoaded").toBoolean() + + private val uncaughtExceptions = CopyOnWriteArrayList() + private var defaultHandler: Thread.UncaughtExceptionHandler? = null + + @Before + fun setUp() { + if (demoName in DEMOS_REQUIRING_MAP_ID) { + assumeTrue("Map ID not specified", hasMapId()) + } + + val handler = Thread.getDefaultUncaughtExceptionHandler() + defaultHandler = handler + Thread.setDefaultUncaughtExceptionHandler { thread, throwable -> + uncaughtExceptions.add(throwable) + // The main looper cannot recover from a crash, so let it take the process down: the + // orchestrator reports it against this test and carries on with the next one. + if (thread == Looper.getMainLooper().thread) handler?.uncaughtException(thread, 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 map ID check skipped the test, restoring + // null would disable crash reporting for later tests. + defaultHandler?.let { Thread.setDefaultUncaughtExceptionHandler(it) } + } + } + + /** Mirrors ApiDemoApplication.mapId, which cannot be read here because it shows a toast. */ + private fun hasMapId(): Boolean { + val context = InstrumentationRegistry.getInstrumentation().targetContext + return BuildConfig.MAP_ID != "MAP_ID" || context.getString(R.string.map_id) != "DEMO_MAP_ID" + } + + @Test + fun demoLaunchesAndShowsMap() { + ActivityScenario.launch(demoActivity).use { scenario -> + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP) { + val map = scenario.awaitMapSurfaceReady() + if (map != null) scenario.zoomOutAndBackIn(map) + } + scenario.assertResumed() + assertNoUncaughtExceptions() + } + } + + /** + * Rotation and other configuration changes are where saved state and retained fragments 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) scenario.awaitMapSurfaceReady() + + scenario.recreate() + + scenario.assertResumed() + if (demoName !in DEMOS_WITHOUT_MAP) scenario.awaitMapSurfaceReady() + assertNoUncaughtExceptions() + } + } + + private fun ActivityScenario.assertResumed() { + assertThat(state).isEqualTo(Lifecycle.State.RESUMED) + } + + /** + * Waits until the demo shows a map or panorama that is laid out on screen, then until the + * Maps SDK reports it ready. Returns the map to zoom, or null when the demo only shows Street + * View. A map fully on screen is preferred: in a scrolling list of maps, one partly off + * screen may never report that it finished loading. + */ + private fun ActivityScenario.awaitMapSurfaceReady(): GoogleMap? { + val deadline = System.currentTimeMillis() + MAP_READY_TIMEOUT_MS + var surfaces = emptyList() + var shown = emptyList() + + while (shown.isEmpty() && System.currentTimeMillis() < deadline) { + onActivity { activity -> + surfaces = activity.mapSurfaces() + shown = surfaces.filter { it.view?.isLaidOutOnScreen() == true } + // Demos such as MapInPager open on a page without a map: swipe on to find it. + if (shown.isEmpty()) activity.window.decorView.turnPagerPage() + } + if (shown.isEmpty()) Thread.sleep(POLL_INTERVAL_MS) + } + if (shown.isEmpty()) { + val detail = if (surfaces.isEmpty()) { + "no map or Street View panorama was found" + } else { + surfaces.joinToString(prefix = "found but not laid out: ") { it.name } + } + throw AssertionError( + "$demoName did not show a map within ${MAP_READY_TIMEOUT_MS}ms: $detail", + ) + } + + val surface = shown.firstOrNull { it.isMap && it.view!!.isFullyOnScreen() } + ?: shown.firstOrNull { it.isMap } + ?: shown.first() + var map: GoogleMap? = null + val ready = CountDownLatch(1) + onActivity { + surface.awaitReady { readyMap -> + map = readyMap + ready.countDown() + } + } + assertWithMessage("$demoName ${surface.name} was not ready within ${MAP_READY_TIMEOUT_MS}ms") + .that(ready.await(MAP_READY_TIMEOUT_MS, TimeUnit.MILLISECONDS)) + .isTrue() + return map + } + + /** + * Zooms the map out and back in. When [requireMapLoaded] is set, also waits for the tiles to + * render each time. This replaces the demo's own map loaded callback, if it had one, for the + * rest of the test. + */ + private fun ActivityScenario.zoomOutAndBackIn(map: GoogleMap) { + 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) + } + + /** + * A map or panorama shown by a demo, either as a view or as a fragment. [awaitReady] calls + * back with the [GoogleMap] for a map, or with null for a panorama once it is ready. + */ + private class MapSurface( + val name: String, + val view: View?, + val isMap: Boolean, + val awaitReady: ((GoogleMap?) -> Unit) -> Unit, + ) + + private fun mapSurface(name: String, view: View?, getMapAsync: (OnMapReadyCallback) -> Unit) = + MapSurface(name, view, isMap = true) { callback -> getMapAsync { callback(it) } } + + private fun panoramaSurface( + name: String, + view: View?, + getPanoramaAsync: (() -> Unit) -> Unit, + ) = MapSurface(name, view, isMap = false) { callback -> getPanoramaAsync { callback(null) } } + + /** Every map and panorama in the activity, from its views and its fragments. */ + private fun Activity.mapSurfaces(): List = buildList { + fun walk(view: View) { + when (view) { + is MapView -> add(mapSurface("MapView", view, view::getMapAsync)) + is StreetViewPanoramaView -> add( + panoramaSurface("StreetViewPanoramaView", view) { onReady -> + view.getStreetViewPanoramaAsync { onReady() } + }, + ) + } + if (view is ViewGroup) { + for (index in 0 until view.childCount) walk(view.getChildAt(index)) + } + } + + fun walk(fragment: Fragment) { + when (fragment) { + is SupportMapFragment -> add( + mapSurface("SupportMapFragment", fragment.view, fragment::getMapAsync), + ) + is SupportStreetViewPanoramaFragment -> add( + panoramaSurface("SupportStreetViewPanoramaFragment", fragment.view) { onReady -> + fragment.getStreetViewPanoramaAsync { onReady() } + }, + ) + } + fragment.childFragmentManager.fragments.forEach(::walk) + } + + walk(window.decorView) + if (this@mapSurfaces is FragmentActivity) { + supportFragmentManager.fragments.forEach(::walk) + } + } + + /** Moves the first pager below this view that has a page left to its next page. */ + private fun View.turnPagerPage(): Boolean { + if (this is ViewPager && currentItem < (adapter?.count ?: 0) - 1) { + currentItem += 1 + return true + } + return this is ViewGroup && (0 until childCount).any { getChildAt(it).turnPagerPage() } + } + + private fun View.isLaidOutOnScreen(): Boolean = isShown && width > 0 && height > 0 + + /** True when this view is shown and its whole area is visible on screen. */ + private fun View.isFullyOnScreen(): Boolean { + if (!isLaidOutOnScreen()) return false + val visible = Rect() + return getGlobalVisibleRect(visible) && + visible.width() == width && + visible.height() == height + } +} diff --git a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/BackgroundColorCustomizationProgrammaticDemoActivity.kt b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/BackgroundColorCustomizationProgrammaticDemoActivity.kt index a7fff2c83..804c3f045 100644 --- a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/BackgroundColorCustomizationProgrammaticDemoActivity.kt +++ b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/BackgroundColorCustomizationProgrammaticDemoActivity.kt @@ -21,10 +21,10 @@ import android.view.View import com.google.android.gms.maps.SupportMapFragment import com.google.android.gms.maps.GoogleMapOptions import com.google.android.gms.maps.GoogleMap -import android.widget.CheckBox import com.google.android.gms.maps.model.MarkerOptions import com.google.android.gms.maps.model.LatLng import com.example.common_ui.R +import com.google.android.material.switchmaterial.SwitchMaterial /** * This shows how to to instantiate a SupportMapFragment programmatically with a custom background @@ -55,15 +55,14 @@ class BackgroundColorCustomizationProgrammaticDemoActivity : SamplesBaseActivity val fragmentTransaction = supportFragmentManager.beginTransaction() fragmentTransaction.replace(R.id.map, mapFragment, MAP_FRAGMENT_TAG) fragmentTransaction.commit() - } else { - mapFragment.getMapAsync(this) } + mapFragment.getMapAsync(this) applyInsets(findViewById(R.id.map_container)) } override fun onMapReady(map: GoogleMap) { map.mapType = GoogleMap.MAP_TYPE_NONE - val mapTypeToggleCheckbox = findViewById(R.id.map_type_toggle) + val mapTypeToggleCheckbox = findViewById(R.id.map_type_toggle) mapTypeToggleCheckbox.setOnCheckedChangeListener { _, isChecked -> map.mapType = if (isChecked) GoogleMap.MAP_TYPE_NORMAL else GoogleMap.MAP_TYPE_NONE } diff --git a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/CircleDemoActivity.kt b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/CircleDemoActivity.kt index 9d1217d23..7cf39a9a7 100644 --- a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/CircleDemoActivity.kt +++ b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/CircleDemoActivity.kt @@ -251,7 +251,7 @@ class CircleDemoActivity : SamplesBaseActivity(), private fun getSelectedPattern(pos: Int): List? = patterns[pos].second - override fun onItemSelected(parent: AdapterView<*>, view: View, pos: Int, id: Long) { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { if (parent.id == R.id.strokePatternSpinner) { circles.map { it.setStrokePattern(getSelectedPattern(pos)) } } diff --git a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/LayersDemoActivity.kt b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/LayersDemoActivity.kt index 35d2c64fe..ba36a582a 100644 --- a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/LayersDemoActivity.kt +++ b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/LayersDemoActivity.kt @@ -194,7 +194,7 @@ class LayersDemoActivity : /** * Called as part of the AdapterView.OnItemSelectedListener */ - override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) { + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { updateMapType() } diff --git a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/PolylineDemoActivity.kt b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/PolylineDemoActivity.kt index e0c8f7b03..273166a3d 100644 --- a/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/PolylineDemoActivity.kt +++ b/ApiDemos/project/kotlin-app/src/main/java/com/example/kotlindemos/PolylineDemoActivity.kt @@ -279,7 +279,7 @@ class PolylineDemoActivity : * Listener for changes in a spinner's position. * Can change the polyline's start and end caps, pattern and joint type. */ - override fun onItemSelected(parent: AdapterView<*>, view: View, pos: Int, id: Long) { + override fun onItemSelected(parent: AdapterView<*>, view: View?, pos: Int, id: Long) { when (parent.id) { com.example.common_ui.R.id.startCapSpinner -> mutablePolyline.startCap = getSelectedCap(pos) ?: ButtCap() com.example.common_ui.R.id.endCapSpinner -> mutablePolyline.endCap = getSelectedCap(pos) ?: ButtCap() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f2c4ccacd..2dfc7bb3d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -58,6 +58,8 @@ espresso = "3.7.0" junit = "4.13.2" mockito = "6.3.0" robolectric = "4.17" +androidxTestOrchestrator = "1.6.1" +androidxTestRules = "1.7.0" truth = "1.4.5" turbine = "1.2.1" uiautomator = "2.4.0" @@ -132,6 +134,8 @@ hilt-navigation-compose = { module = "androidx.hilt:hilt-navigation-compose", ve # Testing espresso-core = { module = "androidx.test.espresso:espresso-core", version.ref = "espresso" } espresso-idling-resource = { module = "androidx.test.espresso:espresso-idling-resource", version.ref = "espresso" } +androidx-test-orchestrator = { module = "androidx.test:orchestrator", version.ref = "androidxTestOrchestrator" } +androidx-test-rules = { module = "androidx.test:rules", version.ref = "androidxTestRules" } ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxJunit" } junit = { group = "junit", name = "junit", version.ref = "junit" } kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinxCoroutinesTest" }