diff --git a/.github/actions/submit-test/action.yml b/.github/actions/submit-test/action.yml index 4145499adb..636958c051 100644 --- a/.github/actions/submit-test/action.yml +++ b/.github/actions/submit-test/action.yml @@ -11,169 +11,112 @@ # 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. -name: Submit to survey +name: Run all tests in the e2eTest module inputs: - android-repository: - description: 'ground-android repository under test' - default: google/ground-android + android-repository: + description: 'ground-android repository under test' + default: google/ground-android - platform-repository: - description: 'ground-platform repository under test (if applicable)' - default: google/ground-platform + upload-artifacts: + description: 'Whether to upload the final emulator data artifacts' + default: 'false' - use-repo-data: - description: 'Whether to use the local repository emulator data or not' - default: 'true' - - upload-artifacts: - description: 'Whether to upload the final emulator data artifacts' - default: 'false' - - google-maps-key: - description: 'A Google Maps API key' + google-maps-key: + description: 'A Google Maps API key' runs: - using: composite - steps: - - name: Enable KVM group perms - shell: bash - 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 - ls /dev/kvm - - - name: Gradle cache - uses: gradle/actions/setup-gradle@v3 - - - name: AVD cache - uses: actions/cache@v4 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-24 - - - name: Checkout - uses: actions/checkout@v4 - with: - repository: ${{ inputs.android-repository }} - - - name: Set up JDK 17 - uses: actions/setup-java@v4 - with: - distribution: 'zulu' - java-version: 17 - - - name: Setup Gradle - uses: gradle/gradle-build-action@v3 - - - name: Set up Node.js 18 - uses: actions/setup-node@v4 - with: - node-version: 18 - - - name: Checkout ground-platform - uses: actions/checkout@v4 - with: - repository: ${{ inputs.platform-repository }} - path: ground-platform - - - name: Cache node modules - id: cache-npm - uses: actions/cache@v3 - env: - cache-name: cache-node-modules - with: - # npm cache files are stored in `~/.npm` on Linux/macOS - path: ~/.npm - key: ${{ runner.os }}-build-${{ env.cache-name }}-${{ hashFiles('**/package-lock.json') }} - restore-keys: | - ${{ runner.os }}-build-${{ env.cache-name }}- - ${{ runner.os }}-build- - ${{ runner.os }}- - - - name: Build ground functions - shell: bash - run: | - cd ground-platform - npm run build:local - cd ../ - - - name: Install firebase-tools - shell: bash - run: | - npm install -g firebase-tools - - - name: Cache Firebase emulator - uses: actions/cache@v4 - with: - path: ~/.cache/firebase/emulators - key: ${{ runner.os }}-firebase-emulators-${{ github.sha }} - restore-keys: | - ${{ runner.os }}-firebase-emulators- - - - name: Copy Firebase emulator data - uses: actions/download-artifact@v4 - if: inputs.use-repo-data != 'true' - with: - name: data-create - path: data/ - - - name: Copy the local repo data - if: inputs.use-repo-data == 'true' - shell: bash - run: cp -r ground-platform/data/test-create ground-platform/data/test - - - name: Replace Google Maps API key - shell: bash - env: - GOOGLE_MAPS_KEY: ${{ inputs.google-maps-key }} - run: | - sed -E -i 's/("current_key": ")[[:alnum:]_-]+(")/\1'"$GOOGLE_MAPS_KEY"'\2/' app/src/debug/local/google-services.json - - - name: Move the local google-services.json - shell: bash - run: | - cp -r app/src/debug/local/google-services.json app/src/debug/ - - - name: Build projects and run instrumentation tests - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: 24 - target: google_apis_playstore - force-avd-creation: false - emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back emulated -logcat '*:e' - disable-animations: true - script: | - firebase emulators:exec './gradlew :e2eTest:connectedLocalDebugAndroidTest --stacktrace' --config ground-platform/firebase.local.json --project local --import data/test --export-on-exit data/test - - - name: Upload test reports - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-reports - path: '**/build/reports/androidTests' - - - name: Upload screenshots - if: always() - uses: actions/upload-artifact@v4 - with: - name: test-screenshots - path: '**/build/outputs/connected_android_test_additional_output' - - - name: Move Firebase emulator data (avoids .gitignore) - shell: bash - run: mv data/test/ ./test - - - name: Copy Firebase emulator data - if: inputs.upload-artifacts == 'true' - uses: actions/upload-artifact@v4 - with: - name: data-submit - path: '**/test' - retention-days: 7 - overwrite: true - if-no-files-found: error \ No newline at end of file + using: composite + steps: + - name: Checkout + uses: actions/checkout@v6 + with: + repository: ${{ inputs.android-repository }} + clean: false + + - name: Check Firebase emulator connection + shell: bash + run: | + echo "Checking connection to Firebase emulator..." + if curl -v http://localhost:4000; then + echo "Successfully connected to Firebase emulator!" + else + echo "Failed to connect to Firebase emulator (http://localhost:4000)" + exit 1 + fi + + - name: Validate and setup MAPS_API_KEY + env: + MAPS_API_KEY: ${{ inputs.google-maps-key }} + shell: bash + run: | + if [ -z "${MAPS_API_KEY}" ]; then + echo "MAPS_API_KEY is missing from GitHub secrets" + exit 1 + fi + echo "MAPS_API_KEY is set" + echo "MAPS_API_KEY=${{ secrets.MAPS_API_KEY }}" > secrets.properties + + - name: Enable KVM group perms + shell: bash + 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: Setup JDK 17 + uses: actions/setup-java@v5 + with: + distribution: 'temurin' + java-version: 17 + + - name: Gradle cache + uses: gradle/actions/setup-gradle@v5 + + - name: Run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 30 + target: google_apis + arch: x86_64 + emulator-options: -no-window -gpu swiftshader_indirect -no-snapshot -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: | + adb wait-for-device + adb emu geo fix -122.084 37.422 + sleep 2 + ./gradlew connectedLocalDebugAndroidTest --stacktrace + + - name: Upload test reports + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-reports + path: '**/build/reports/androidTests' + retention-days: 7 + if-no-files-found: warn + + - name: Upload test screenshots + if: always() + uses: actions/upload-artifact@v6 + with: + name: test-screenshots + path: '**/build/outputs/connected_android_test_additional_output' + retention-days: 7 + if-no-files-found: ignore + + - name: Export Firebase emulator data + if: always() + shell: bash + run: | + echo "Exporting emulator data..." + npx -y -p firebase-tools firebase emulators:export ${{ github.workspace }}/test-android-result --project demo-local --force + + - name: Upload Firebase emulator data + if: always() && inputs.upload-artifacts == 'true' + uses: actions/upload-artifact@v6 + with: + name: firebase-emulator-data + path: ${{ github.workspace }}/test-android-result + retention-days: 7 + if-no-files-found: warn diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cae4d3de85..ef580d9247 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,3 +54,12 @@ jobs: curl -Os https://uploader.codecov.io/latest/linux/codecov chmod +x codecov ./codecov --verbose upload-process --fail-on-error -t ${{ secrets.CODECOV_TOKEN }} -n 'service'-${{ github.run_id }} -F service -f app/build/reports/jacoco/jacocoLocalDebugUnitTestReport/jacocoLocalDebugUnitTestReport.xml + + instrumentation-tests: + needs: build + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + uses: ./.github/workflows/test-e2e.yml + secrets: + MAPS_API_KEY: ${{ secrets.MAPS_API_KEY }} + permissions: + contents: read diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml index 565a7775be..eb1f45b459 100644 --- a/.github/workflows/test-e2e.yml +++ b/.github/workflows/test-e2e.yml @@ -14,53 +14,33 @@ name: End to End Test on: - issue_comment: - types: [created] + workflow_dispatch: + workflow_call: + secrets: + MAPS_API_KEY: + required: true + +permissions: + contents: read jobs: - e2eTest: + run-instrumentation-tests: runs-on: ubuntu-latest - timeout-minutes: 15 - if: github.event.issue.pull_request && contains(github.event.comment.body, '/e2eTest') - steps: - - name: Start test - run: | - echo "Begin end to end test" - + timeout-minutes: 30 + env: + FIREBASE_CLI_EXPERIMENTS: webframeworks - createTest: - needs: e2eTest - name: Create a new survey - runs-on: ubuntu-latest - timeout-minutes: 10 + # See docs/e2e-testing-doc.md for details on how this is setup steps: - - name: Run create-test - uses: google/ground-platform/.github/actions/create-test@master - with: - upload-artifacts: true + - name: Checkout repository + uses: actions/checkout@v6 - submitTest: - needs: createTest - name: Submit to survey - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Run submit-test - uses: ./.github/actions/submit-test + - name: Start Firebase emulator + uses: google/ground-platform/.github/actions/start-emulator@master + + - name: Run Android tests on e2eTest module + uses: google/ground-android/.github/actions/submit-test@master with: android-repository: ${{ github.repository }} - google-maps-key: ${{ secrets.GOOGLE_MAPS_KEY }} - use-repo-data: false upload-artifacts: true - - - verifyTest: - needs: submitTest - name: Verify survey submissions - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - name: Run verify-test - uses: google/ground-platform/.github/actions/verify-test@master - with: - use-repo-data: false + google-maps-key: ${{ secrets.MAPS_API_KEY }} \ No newline at end of file diff --git a/.github/workflows/test-submit.yml b/.github/workflows/test-submit.yml deleted file mode 100644 index 90a329a918..0000000000 --- a/.github/workflows/test-submit.yml +++ /dev/null @@ -1,35 +0,0 @@ -# Copyright 2024 The Ground Authors. -# -# 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 -# -# https://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. -name: Submission Test - -on: - issue_comment: - types: [created] - -jobs: - submitTest: - if: github.event.issue.pull_request && contains(github.event.comment.body, '/submitTest') - name: Submit to survey - runs-on: ubuntu-latest - timeout-minutes: 15 - steps: - - name: Checkout code - uses: actions/checkout@v6 - - name: Run submit-test - uses: ./.github/actions/submit-test - with: - android-repository: ${{ github.repository }} - google-maps-key: ${{ secrets.GOOGLE_MAPS_KEY }} - use-repo-data: true - upload-artifacts: false \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7d9736a185..e6dda87116 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ secrets.properties !app/src/debug/local/google-services.json !.gitignore !gradle/ +!.github/ # Taken from Android.gitignore https://github.com/github/gitignore/blob/master/Android.gitignore # diff --git a/docs/e2e-testing-doc.md b/docs/e2e-testing-doc.md new file mode 100644 index 0000000000..adc93c1c66 --- /dev/null +++ b/docs/e2e-testing-doc.md @@ -0,0 +1,50 @@ +# End-to-End (E2E) testing on Android + +## Current setup + +The Android E2E tests rely on a predefined test survey created by the web application and stored in +the Firebase emulator data + +### 1. Predefined test survey + +The test data was originally generated by running the web application locally against the Firebase +emulator and exporting the emulator state. This ensures a consistent starting state for tests + +### 2. Test Execution Flow + +The CI workflow (and local execution) follows these steps: + +1. **Start Firebase Emulator**: Loads the predefined data +2. **Run Tests**: Executes Android instrumentation tests in the `e2eTest` module against the + emulator + +## Managing test data and local testing + +### Running tests locally +1. Clone `ground-platform` and set up the project according to the [documentation](https://github.com/google/ground-platform) +2. Run the following command to launch the local Firebase emulator with test data: + ```bash + # From the ground-platform directory + nx start-android-test-data + ``` +3. Run all tests on `e2eTest` module using the `localDebug` build variant + +### Updating test data +Since the Android app relies on specific survey structures, changes to the survey schema may require +updating the test data. Follow the steps in "Running tests locally" above, then: +1. Manually set up or update the survey (via the web UI) to match the scenarios expected by the Android `e2eTest` module. +2. Verify that all tests pass locally using the `localDebug` build variant +3. Export the updated emulator data to persist changes: + ```bash + firebase emulators:export data/test-android --project demo-local + ``` + *Note: This overwrites the data in `data/test-android`.* + +## Limitations + +This setup does not fully prevent data drift between the web and Android apps. Any structural +changes in surveys require manual updates to the predefined test data. + +This could be improved by implementing web E2E tests that generate Firestore test data on each run +and trigger the Android E2E tests. Optionally, the web tests could also verify the final persisted +state. \ No newline at end of file diff --git a/e2eTest/build.gradle b/e2eTest/build.gradle index c710a8dd37..f76fbe1d9f 100644 --- a/e2eTest/build.gradle +++ b/e2eTest/build.gradle @@ -69,6 +69,7 @@ dependencies { implementation libs.androidx.appcompat implementation libs.androidx.core.ktx implementation libs.androidx.espresso.core + implementation libs.androidx.espresso.intents implementation libs.androidx.junit implementation libs.androidx.rules implementation libs.androidx.uiautomator diff --git a/e2eTest/src/main/assets/e2e_test_photo.webp b/e2eTest/src/main/assets/e2e_test_photo.webp new file mode 100644 index 0000000000..45449aa284 Binary files /dev/null and b/e2eTest/src/main/assets/e2e_test_photo.webp differ diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt index 336fab092d..60b31f7554 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/TestConfig.kt @@ -17,6 +17,13 @@ package org.groundplatform.android.e2etest import org.groundplatform.android.model.task.Task +/** + * This file contains configuration constants and test data for the E2E tests. All surveys, jobs, + * tasks, and other test data here must match the corresponding entries in the local Firebase + * emulator. Mismatched values may cause E2E tests to fail. + * + * For more details on how E2E tests work see: docs/e2e-testing-doc.md + */ object TestConfig { const val DEFAULT_TIMEOUT = 10000L const val SURVEY_NAME = "Ground app E2E test" @@ -37,4 +44,5 @@ object TestConfig { const val TEST_JOB_DRAW_AREA = "Test draw area" val TEST_LIST_DRAW_AREA = listOf(TestTask(taskType = Task.Type.DRAW_AREA, isRequired = true)) const val LOI_NAME = "Test location" + const val TEST_PHOTO_FILE = "e2e_test_photo.webp" } diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt index 886689b496..b52c7dd78a 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/drivers/AndroidTestDriver.kt @@ -15,7 +15,12 @@ */ package org.groundplatform.android.e2etest.drivers +import android.app.Activity +import android.app.Instrumentation +import android.content.Intent import android.graphics.Point +import android.net.Uri +import android.provider.MediaStore import android.widget.DatePicker import android.widget.TimePicker import androidx.compose.ui.test.ExperimentalTestApi @@ -29,14 +34,16 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.performTextInput import androidx.test.espresso.Espresso.onView import androidx.test.espresso.action.ViewActions +import androidx.test.espresso.intent.Intents.intending +import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice -import androidx.test.uiautomator.UiObject -import androidx.test.uiautomator.UiSelector import androidx.test.uiautomator.Until import org.groundplatform.android.R import org.groundplatform.android.e2etest.TestConfig.DEFAULT_TIMEOUT +import org.groundplatform.android.e2etest.TestConfig.TEST_PHOTO_FILE import org.groundplatform.android.e2etest.extensions.onTarget @OptIn(ExperimentalTestApi::class) @@ -103,7 +110,8 @@ class AndroidTestDriver( override fun clickMapMarker(description: String) { wait(TestDriver.Target.ViewId(R.id.map)) - val marker: UiObject = device.findObject(UiSelector().descriptionContains(description)) + val marker = device.wait(Until.findObject(By.descContains(description)), DEFAULT_TIMEOUT) + checkNotNull(marker) { "Marker '$description' not found after $DEFAULT_TIMEOUT ms" } marker.click() } @@ -126,15 +134,21 @@ class AndroidTestDriver( } override fun takePhoto() { - val shutterSelector = By.res("com.android.camera2:id/shutter_button") - val doneSelector = By.res("com.android.camera2:id/done_button") - - val shutterButton = device.wait(Until.findObject(shutterSelector), DEFAULT_TIMEOUT) - checkNotNull(shutterButton) { "Camera 'shutter button' not found after ${DEFAULT_TIMEOUT}ms" } - shutterButton.click() - val doneButton = device.wait(Until.findObject(doneSelector), DEFAULT_TIMEOUT) - checkNotNull(doneButton) { "Camera 'done' button not found after ${DEFAULT_TIMEOUT}ms" } - doneButton.click() + intending(hasAction(MediaStore.ACTION_IMAGE_CAPTURE)).respondWithFunction { intent -> + intent.getParcelableExtra(MediaStore.EXTRA_OUTPUT)?.let { photoUri -> + val testContext = InstrumentationRegistry.getInstrumentation().context + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + + testContext.assets.open(TEST_PHOTO_FILE).use { input -> + appContext.contentResolver.openOutputStream(photoUri)?.use { output -> + input.copyTo(output) + } ?: error("Failed to open output stream for $photoUri") + } + } ?: error("photoUri is null") + Instrumentation.ActivityResult(Activity.RESULT_OK, Intent()) + } + + click(TestDriver.Target.ViewId(R.id.btn_camera)) } override fun setDate() { diff --git a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt index 86605e8932..3ed98d4672 100644 --- a/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt +++ b/e2eTest/src/main/java/org/groundplatform/android/e2etest/robots/DataCollectionRobot.kt @@ -124,7 +124,6 @@ class DataCollectionRobot(override val testDriver: TestDriver) : Robot