diff --git a/.github/workflows/android-e2e.yml b/.github/workflows/android-e2e.yml new file mode 100644 index 000000000..c99314327 --- /dev/null +++ b/.github/workflows/android-e2e.yml @@ -0,0 +1,87 @@ +on: [push] + +jobs: + setup_nextcloud: + runs-on: ubuntu-latest + name: Run e2e test + strategy: + matrix: + api-level: [ 24, 25, 26, 27, 28, 29 ] + nextcloud-version: [ 'nextcloud:latest', 'nextcloud:stable', 'nextcloud:production' ] + services: + nextcloud: + image: ${{ matrix.nextcloud-version }} + env: + SQLITE_DATABASE: db.sqlite + NEXTCLOUD_ADMIN_USER: Test + NEXTCLOUD_ADMIN_PASSWORD: Test + ports: + - 8080:80 + options: >- + --health-cmd "curl GET 'http://Test:Test@localhost:80/ocs/v2.php/apps/serverinfo/api/v1/info' -f -H 'OCS-APIRequest: true' || exit 1" + --health-interval 1s + --health-timeout 2s + --health-retries 10 + --health-start-period 3s + steps: + - name: Checkout + uses: actions/checkout@v2 + + - name: Enable Deck via Docker Exec + run: | + docker exec `docker ps -f 'name=_nextcloud' -l -q` bash -c 'runuser -u www-data -- php occ app:install deck' + + - name: Fetch capabilities + run: | + curl -X GET 'http://Test:Test@localhost:8080/ocs/v2.php/cloud/capabilities?format=json' -H 'OCS-APIRequest: true' | jq + + ########################## + # AVD CACHING START # + ########################## + + - name: Gradle cache + uses: actions/cache@v2 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*') }}-${{ hashFiles('**/gradle/wrapper/gradle-wrapper.properties') }}-${{ hashFiles('**/buildSrc/**/*.kt') }} + + - name: AVD cache + uses: actions/cache@v2 + id: avd-cache + with: + path: | + ~/.android/avd/* + ~/.android/adb* + key: avd-${{ matrix.api-level }} + + - name: Create AVD and generate snapshot for caching + if: steps.avd-cache.outputs.cache-hit != 'true' + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: echo "Generated AVD snapshot for caching." + + ########################## + # AVD CACHING END # + ########################## + + - name: Run e2e tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + force-avd-creation: false + emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none + disable-animations: true + script: | + adb shell pm uninstall -k --user 0 com.nextcloud.android.beta || true + adb shell pm uninstall -k --user 0 it.niedermann.nextcloud.deck.dev || true + wget -q https://download.nextcloud.com/android/dev/latest.apk + adb install latest.apk + sleep 10s + adb logcat -c || true + ./gradlew connectedDevDebugAndroidTest \ No newline at end of file diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index fa6999474..6e735bbae 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -24,11 +24,14 @@ jobs: test: name: Unit tests runs-on: ubuntu-latest + strategy: + matrix: + flavor: [ 'testDevDebugUnitTest', 'testFdroidReleaseUnitTest', 'testPlayReleaseUnitTest' ] steps: - name: Checkout uses: actions/checkout@v2 - name: Unit tests - run: bash ./gradlew test + run: bash ./gradlew ${{ matrix.flavor }} codeql: name: CodeQL security scan diff --git a/app/build.gradle b/app/build.gradle index c94aa8aa6..34ab56407 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -126,4 +126,8 @@ dependencies { testImplementation 'org.mockito:mockito-core:4.0.0' testImplementation 'androidx.test:core:1.4.0' testImplementation 'androidx.arch.core:core-testing:2.1.0' + + // Instrumented tests + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' } diff --git a/app/src/androidTest/java/it/niedermann/nextcloud/deck/ui/E2ETest.java b/app/src/androidTest/java/it/niedermann/nextcloud/deck/ui/E2ETest.java new file mode 100644 index 000000000..ca200baa5 --- /dev/null +++ b/app/src/androidTest/java/it/niedermann/nextcloud/deck/ui/E2ETest.java @@ -0,0 +1,122 @@ +package it.niedermann.nextcloud.deck.ui; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; + +import android.content.Intent; +import android.webkit.WebView; +import android.widget.Button; +import android.widget.EditText; + +import androidx.annotation.NonNull; +import androidx.test.uiautomator.By; +import androidx.test.uiautomator.UiDevice; +import androidx.test.uiautomator.UiObjectNotFoundException; +import androidx.test.uiautomator.UiSelector; +import androidx.test.uiautomator.Until; + +import org.junit.After; +import org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; + +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class E2ETest { + + private UiDevice mDevice; + + private static final int TIMEOUT = 30_000; + + private static final String APP_NEXTCLOUD = "com.nextcloud.android.beta"; + private static final String APP_DECK = "it.niedermann.nextcloud.deck.dev"; + + private static final String SERVER_URL = "http://localhost:8080"; + private static final String SERVER_USERNAME = "Test"; + private static final String SERVER_PASSWORD = "Test"; + + @Before + public void before() { + mDevice = UiDevice.getInstance(getInstrumentation()); + } + + @After + public void after() { + mDevice.pressHome(); + } + + @Test + public void test_00_configureNextcloudAccount() throws UiObjectNotFoundException { + launch(APP_NEXTCLOUD); + + final var loginButton = mDevice.findObject(new UiSelector().text("Log in")); + loginButton.waitForExists(TIMEOUT); + loginButton.click(); + + mDevice.findObject(new UiSelector().focused(true)).setText(SERVER_URL); + mDevice.pressEnter(); + mDevice.findObject(new UiSelector().text("Log in")).click(); + mDevice.wait(Until.findObject(By.clazz(WebView.class)), TIMEOUT); + + final var usernameInput = mDevice.findObject(new UiSelector() + .instance(0) + .className(EditText.class)); + usernameInput.waitForExists(TIMEOUT); + usernameInput.setText(SERVER_USERNAME); + + final var passwordInput = mDevice.findObject(new UiSelector() + .instance(1) + .className(EditText.class)); + passwordInput.waitForExists(TIMEOUT); + passwordInput.setText(SERVER_PASSWORD); + + mDevice.findObject(new UiSelector().text("Log in")).click(); + mDevice.findObject(new UiSelector().text("Grant access")).click(); + } + + @Test + public void test_01_importAccountIntoDeck() throws UiObjectNotFoundException { + launch(APP_DECK); + + final var accountButton = mDevice.findObject(new UiSelector() + .instance(0) + .className(Button.class)); + accountButton.waitForExists(TIMEOUT); + accountButton.click(); + + final var radioAccount = mDevice.findObject(new UiSelector() + .clickable(true) + .instance(0)); + radioAccount.waitForExists(TIMEOUT); + radioAccount.click(); + + final var okButton = mDevice.findObject(new UiSelector().text("OK")); + okButton.waitForExists(TIMEOUT); + okButton.click(); + + final var allowButton = mDevice.findObject(new UiSelector().text("Allow")); + allowButton.waitForExists(TIMEOUT); + allowButton.click(); + + final var welcomeText = mDevice.findObject(new UiSelector().description("Filter")); + welcomeText.waitForExists(TIMEOUT); + } + + @Test + public void test_02_verifyCardsPresent() throws UiObjectNotFoundException { + launch(APP_DECK); + + final var taskCard = mDevice.findObject(new UiSelector() + .textContains("Taask 3")); + taskCard.waitForExists(TIMEOUT); + System.out.println("Found: " + taskCard.getText()); + } + + private void launch(@NonNull String packageName) { + final var context = getInstrumentation().getContext(); + context.startActivity(context + .getPackageManager() + .getLaunchIntentForPackage(packageName) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)); + mDevice.wait(Until.hasObject(By.pkg(packageName).depth(0)), TIMEOUT); + } +}