diff --git a/.github/actions/submit-test/action.yml b/.github/actions/submit-test/action.yml new file mode 100644 index 0000000000..19a4c05e21 --- /dev/null +++ b/.github/actions/submit-test/action.yml @@ -0,0 +1,179 @@ +# Copyright 2024 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 +# +# 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: Submit to survey + +inputs: + 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 + + 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' + +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/' ground/src/debug/local/google-services.json + + - name: Move the local google-services.json + shell: bash + run: | + cp -r ground/src/debug/local/google-services.json ground/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 ground-local-dev --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 diff --git a/.github/workflows/test-e2e.yml b/.github/workflows/test-e2e.yml new file mode 100644 index 0000000000..565a7775be --- /dev/null +++ b/.github/workflows/test-e2e.yml @@ -0,0 +1,66 @@ +# 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: End to End Test + +on: + issue_comment: + types: [created] + +jobs: + e2eTest: + 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" + + + createTest: + needs: e2eTest + name: Create a new survey + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - name: Run create-test + uses: google/ground-platform/.github/actions/create-test@master + with: + upload-artifacts: true + + submitTest: + needs: createTest + name: Submit to survey + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Run submit-test + uses: ./.github/actions/submit-test + 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 diff --git a/.github/workflows/test-submit.yml b/.github/workflows/test-submit.yml new file mode 100644 index 0000000000..4dd9473059 --- /dev/null +++ b/.github/workflows/test-submit.yml @@ -0,0 +1,35 @@ +# 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@v4 + - 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/e2eTest/build.gradle b/e2eTest/build.gradle index e819bdfcd3..62294e7613 100644 --- a/e2eTest/build.gradle +++ b/e2eTest/build.gradle @@ -24,7 +24,6 @@ android { compileSdk rootProject.androidCompileSdk defaultConfig { - applicationId "com.google.android.ground.e2etest" minSdkVersion rootProject.androidMinSdk targetSdkVersion rootProject.androidTargetSdk testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -35,6 +34,10 @@ android { execution 'ANDROIDX_TEST_ORCHESTRATOR' } + buildFeatures { + buildConfig = true + } + buildTypes { staging { } @@ -43,15 +46,19 @@ android { productFlavors { local { dimension "backend" + buildConfigField "boolean", "USE_EMULATORS", "true" } dev { dimension "backend" + buildConfigField "boolean", "USE_EMULATORS", "false" } sig { dimension "backend" + buildConfigField "boolean", "USE_EMULATORS", "false" } ecam { dimension "backend" + buildConfigField "boolean", "USE_EMULATORS", "false" } } } diff --git a/e2eTest/src/main/java/com/google/android/ground/e2etest/SurveyRunnerTest.kt b/e2eTest/src/main/java/com/google/android/ground/e2etest/SurveyRunnerTest.kt index 723228340e..7a4e4f692d 100644 --- a/e2eTest/src/main/java/com/google/android/ground/e2etest/SurveyRunnerTest.kt +++ b/e2eTest/src/main/java/com/google/android/ground/e2etest/SurveyRunnerTest.kt @@ -15,12 +15,15 @@ */ package com.google.android.ground.e2etest +import android.util.Log import android.widget.Button import android.widget.CheckBox import android.widget.EditText import android.widget.RadioButton import android.widget.TextView import androidx.cardview.widget.CardView +import androidx.test.core.app.takeScreenshot +import androidx.test.core.graphics.writeToTestStorage import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import androidx.test.uiautomator.By @@ -34,13 +37,18 @@ import com.google.android.ground.e2etest.TestConfig.SHORT_TIMEOUT import com.google.android.ground.e2etest.TestConfig.TEST_SURVEY_IDENTIFIER import com.google.android.ground.e2etest.TestConfig.TEST_SURVEY_TASKS_ADHOC import com.google.android.ground.model.task.Task +import java.io.IOException import junit.framework.TestCase.fail +import org.junit.Rule import org.junit.Test +import org.junit.rules.TestName import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class SurveyRunnerTest : AutomatorRunner { + @get:Rule var nameRule = TestName() + override lateinit var device: UiDevice @Test @@ -65,25 +73,39 @@ class SurveyRunnerTest : AutomatorRunner { // Wait for the app to appear. if (!device.wait(Until.hasObject(By.pkg(GROUND_PACKAGE).depth(0)), LONG_TIMEOUT)) { + captureScreenshot() fail("Failed to launch app.") } + device.wait(Until.hasObject(byText(R.string.initializing)), SHORT_TIMEOUT) + if (device.wait(Until.gone(byText(R.string.initializing)), LONG_TIMEOUT) == null) { + captureScreenshot() + fail("Timed out while initializing.") + } } private fun signIn() { if (!waitClickGone(byClass(Button::class), LONG_TIMEOUT)) { + captureScreenshot() fail("Failed to sign in.") } } private fun selectTestSurvey() { - device.wait(Until.hasObject(byText(R.string.select_survey_title)), LONG_TIMEOUT) + if (device.wait(Until.hasObject(byText(R.string.select_survey_title)), LONG_TIMEOUT) == null) { + captureScreenshot() + fail("Failed to find select survey title") + } val testSurveySelector = byClass(CardView::class) .hasDescendant(byClass(TextView::class).textContains(TEST_SURVEY_IDENTIFIER)) - device.wait(Until.hasObject(testSurveySelector), LONG_TIMEOUT) + if (device.wait(Until.hasObject(testSurveySelector), LONG_TIMEOUT) == null) { + captureScreenshot() + fail("Failed to find test survey") + } // Need to double click on survey. waitClickGone(testSurveySelector) if (!waitClickGone(testSurveySelector, timeout = LONG_TIMEOUT)) { + captureScreenshot() fail("Failed to select survey.") } } @@ -93,29 +115,38 @@ class SurveyRunnerTest : AutomatorRunner { allowPermissions() val loiCardSelector = byClass(CardView::class).hasDescendant(byText(R.string.collect_data)) if (device.wait(Until.hasObject(loiCardSelector), LONG_TIMEOUT) == null) { + captureScreenshot() fail("Failed to zoom in to location.") } } private fun startAdHocLoiTask() { val loiCardSelector = byClass(CardView::class) - device.wait(Until.hasObject(loiCardSelector), LONG_TIMEOUT) + if (device.wait(Until.hasObject(loiCardSelector), LONG_TIMEOUT) == null) { + captureScreenshot() + fail("Failed to find ad-hoc loi card") + } val cards = device.findObjects(loiCardSelector) cards.forEach { it.swipe(Direction.LEFT, 1F) } val loiCollectDataButtonSelector = byText(R.string.collect_data) .hasAncestor(loiCardSelector.hasDescendant(byText(R.string.new_site))) if (!waitClickGone(loiCollectDataButtonSelector)) { + captureScreenshot() fail("Failed to start ad-hoc loi data collection.") } } private fun startPredefinedLoiTask() { val loiCardSelector = byClass(CardView::class) - device.wait(Until.hasObject(loiCardSelector), LONG_TIMEOUT) + if (device.wait(Until.hasObject(loiCardSelector), LONG_TIMEOUT) == null) { + captureScreenshot() + fail("Failed to find predefined loi card") + } // Assume that the first card is the predefined LOI. val loiCollectDataButtonSelector = byText(R.string.collect_data) if (!waitClickGone(loiCollectDataButtonSelector)) { + captureScreenshot() fail("Failed to start predefined loi data collection.") } } @@ -219,8 +250,22 @@ class SurveyRunnerTest : AutomatorRunner { } private fun setLoiName() { - device.wait(Until.hasObject(byText(R.string.save)), SHORT_TIMEOUT) + captureScreenshot() + if (device.wait(Until.hasObject(byText(R.string.save)), SHORT_TIMEOUT) == null) { + captureScreenshot() + fail("Failed to find loi name popup") + } enterText("An loi name") waitClickGone(byText(R.string.save)) } + + private fun captureScreenshot() { + val screenShotName = "${javaClass.simpleName}_${nameRule.methodName}" + Log.d("Screenshots", "Taking screenshot of '$screenShotName'") + try { + takeScreenshot().writeToTestStorage(screenShotName) + } catch (ex: IOException) { + Log.e("Screenshots", "Could not take the screenshot", ex) + } + } } diff --git a/e2eTest/src/main/java/com/google/android/ground/e2etest/TestConfig.kt b/e2eTest/src/main/java/com/google/android/ground/e2etest/TestConfig.kt index 0d0763239d..e74f2158c6 100644 --- a/e2eTest/src/main/java/com/google/android/ground/e2etest/TestConfig.kt +++ b/e2eTest/src/main/java/com/google/android/ground/e2etest/TestConfig.kt @@ -18,8 +18,8 @@ package com.google.android.ground.e2etest import com.google.android.ground.model.task.Task object TestConfig { - const val LONG_TIMEOUT = 3000L - const val SHORT_TIMEOUT = 1000L + const val LONG_TIMEOUT = 30000L + const val SHORT_TIMEOUT = 10000L const val GROUND_PACKAGE = "com.google.android.ground" val TEST_SURVEY_TASKS_ADHOC = listOf(