diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 00000000..f138aaa1 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,88 @@ +on: [push] + +jobs: + setup_nextcloud: + runs-on: ubuntu-latest + name: Run e2e test + strategy: + fail-fast: false + matrix: + api-level: [ 28 ] #, 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 + NEXTCLOUD_TRUSTED_DOMAINS: 172.17.0.1 + 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 + + # TODO 172.17.0.1 is the hard coded IP address of the docker container. Make this more generic. + - name: Verify Nextcloud being present + run: | + curl -v -X GET 'http://Test:Test@172.17.0.1: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 +# sdcard-path-or-size: sdcard +# emulator-options: -gpu swiftshader_indirect -no-window -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 + wget -q https://download.nextcloud.com/android/dev/latest.apk + adb install latest.apk + adb shell pm grant com.nextcloud.android.beta android.permission.READ_EXTERNAL_STORAGE + adb logcat -c || true + adb logcat -s "E2E" -v color & + adb logcat *:I -v color & + ./gradlew :sample:connectedDebugAndroidTest diff --git a/build.gradle b/build.gradle index 3dfd1ec2..065b46bb 100644 --- a/build.gradle +++ b/build.gradle @@ -22,6 +22,10 @@ buildscript { } } +//plugins { +// id "de.undercouch.download" version "5.4.0" +//} + allprojects { repositories { google() diff --git a/sample/build.gradle b/sample/build.gradle index b323e854..ef75b9e4 100644 --- a/sample/build.gradle +++ b/sample/build.gradle @@ -4,7 +4,7 @@ * SPDX-FileCopyrightText: 2016-2024 Nextcloud GmbH and Nextcloud contributors * SPDX-License-Identifier: GPL-3.0-or-later */ - + apply plugin: 'com.android.application' android { @@ -35,6 +35,34 @@ android { } } +//task downloadNextcloudApk(type: Download) { +// src 'https://download.nextcloud.com/android/dev/latest.apk' +// dest new File(buildDir, 'latest.apk') +// overwrite true +//} +// +//task setupNextcloudEnvironment(dependsOn: downloadNextcloudApk) { +// def bridge = AndroidDebugBridge.createBridge(android.adbExecutable.path, false, 10, TimeUnit.SECONDS) +// doLast { +// bridge.devices.each { device -> +// println "Uninstall Nextcloud apk from ${device.name}" +// device.uninstallPackage("com.nextcloud.android.beta") +// +// println "Install Nextcloud apk on ${device.name}" +// device.installPackage(new File(buildDir, 'latest.apk').getAbsolutePath(), true) +// +// println "Grant permissions to Nextcloud" +// device.executeShellCommand("pm grant com.nextcloud.android.beta android.permission.READ_EXTERNAL_STORAGE", NullOutputReceiver.receiver, 3, TimeUnit.SECONDS) +// } +// } +//} +// +//tasks.whenTaskAdded { taskItem -> +// if (taskItem.name.contains("connected") && taskItem.name.endsWith("AndroidTest")) { +// taskItem.dependsOn setupNextcloudEnvironment +// } +//} + dependencies { coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.0.4' implementation(platform("org.jetbrains.kotlin:kotlin-bom:1.9.22")) @@ -45,8 +73,6 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:2.1.4' implementation 'com.squareup.retrofit2:retrofit:2.9.0' - testImplementation 'junit:junit:4.13.2' - - androidTestImplementation 'androidx.test.ext:junit:1.1.5' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1' + androidTestImplementation 'androidx.test:runner:1.4.0' + androidTestImplementation 'androidx.test.uiautomator:uiautomator:2.2.0' } \ No newline at end of file diff --git a/sample/src/androidTest/java/com/nextcloud/android/sso/sample/E2ETest.java b/sample/src/androidTest/java/com/nextcloud/android/sso/sample/E2ETest.java new file mode 100644 index 00000000..f7e754ee --- /dev/null +++ b/sample/src/androidTest/java/com/nextcloud/android/sso/sample/E2ETest.java @@ -0,0 +1,219 @@ +package com.nextcloud.android.sso.sample; + +import static androidx.test.platform.app.InstrumentationRegistry.getInstrumentation; +import static androidx.test.uiautomator.Until.hasObject; + +import android.content.Intent; +import android.content.pm.PackageManager; +import android.util.Log; +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 org.junit.Before; +import org.junit.FixMethodOrder; +import org.junit.Test; +import org.junit.runners.MethodSorters; + +/** + *

Setup

+ *

CI / CD

+ *

No manual configuration needs to be done because the setup already happens in the e2e.yml file.

+ *

Local

+ *
    + *
  1. Set {@link #CONFIG_SERVER_URL}, {@link #CONFIG_USERNAME}, {@link #CONFIG_PASSWORD} and {@link #CONFIG_DISPLAY_NAME}. The Nextcloud instance must exist and be reachable.
  2. + *
  3. Remove any existing installation of the Nextcloud files app
  4. + *
  5. Install the Dev-Version of the Nextcloud files app
  6. + *
  7. Grant the android.permission.READ_EXTERNAL_STORAGE permission to the Nextcloud files app
  8. + *
+ */ +@FixMethodOrder(MethodSorters.NAME_ASCENDING) +public class E2ETest { + private static final String CONFIG_SERVER_URL = "http://172.17.0.1:8080"; + private static final String CONFIG_USERNAME = "Test"; + private static final String CONFIG_DISPLAY_NAME = "Test"; + private static final String CONFIG_PASSWORD = "Test"; + + private static final String TAG = "E2E"; + private static final int TIMEOUT = 60_000; + + private UiDevice mDevice; + + private static final String APP_SAMPLE = BuildConfig.APPLICATION_ID; + // TODO This should be passed as argument + private static final String APP_NEXTCLOUD = "com.nextcloud.android.beta"; + + @Before + public void before() { + mDevice = UiDevice.getInstance(getInstrumentation()); + } + + @Test + public void test_00_configureNextcloudAccount() throws UiObjectNotFoundException, InterruptedException { + Log.i(TAG, "Configure Nextcloud account"); + + final var context = getInstrumentation().getContext(); + final var packageManager = context.getPackageManager(); + try { + packageManager.getPackageInfo(APP_NEXTCLOUD, 0); + Log.i(TAG, "Nextcloud APK is installed (checking on runtime)"); + } catch (PackageManager.NameNotFoundException e) { + Log.e(TAG, "Nextcloud APK is NOT installed (checking on runtime"); + } + + launch(APP_NEXTCLOUD); + + final var loginButton = mDevice.findObject(new UiSelector().textContains("Log in")); + loginButton.waitForExists(TIMEOUT); + Log.d(TAG, "Login Button exists. Clicking on it…"); + loginButton.click(); + Log.d(TAG, "Login Button clicked."); + + final var urlInput = mDevice.findObject(new UiSelector().focused(true)); + urlInput.waitForExists(TIMEOUT); + Log.d(TAG, "URL input exists."); + Log.d(TAG, "Entering URL…"); + urlInput.setText(CONFIG_SERVER_URL); + Log.d(TAG, "URL entered."); + + Log.d(TAG, "Pressing enter…"); + mDevice.pressEnter(); + Log.d(TAG, "Enter pressed."); + + final var webView = mDevice.findObject(new UiSelector().instance(0).className(WebView.class)); + Log.d(TAG, "Waiting for WebView…"); +// mDevice.wait(findObject(By.clazz(WebView.class)), TIMEOUT); + webView.waitForExists(TIMEOUT); + Log.d(TAG, "WebView exists."); + + final var webViewLoginButton = mDevice.findObject(new UiSelector() + .instance(0) + .className(Button.class)); + Log.d(TAG, "Waiting for WebView Login Button…"); + webViewLoginButton.waitForExists(TIMEOUT); + Log.d(TAG, "WebView Login Button exists. Clicking on it…"); + + // TODO Find better way to scroll the Login button to the visible area + // Log.d(TAG, "Scroll to bottom of WebView…"); + // mDevice.findObject(By.clazz(WebView.class)).swipe(Direction.UP, 1f); + // Log.d(TAG, "Finished scrolling"); + webViewLoginButton.dragTo(0, 0, 40); + + webViewLoginButton.click(); + + final var usernameInput = mDevice.findObject(new UiSelector() + .instance(0) + .className(EditText.class)); + Log.d(TAG, "Waiting for Username Input…"); + usernameInput.waitForExists(TIMEOUT); + Log.d(TAG, "Username Input exists. Setting text…"); + usernameInput.setText(CONFIG_USERNAME); + Log.d(TAG, "Username has been set."); + + final var passwordInput = mDevice.findObject(new UiSelector() + .instance(1) + .className(EditText.class)); + Log.d(TAG, "Waiting for Password Input…"); + passwordInput.waitForExists(TIMEOUT); + Log.d(TAG, "Password Input exists. Setting text…"); + passwordInput.setText(CONFIG_PASSWORD); + + // mDevice.pressEnter(); + final var webViewSubmitButton = mDevice.findObject(new UiSelector() + .instance(1) // First button is password visibility toggle + .className(Button.class)); + Log.d(TAG, "Waiting for WebView Submit Button…"); + webViewSubmitButton.waitForExists(TIMEOUT); + Log.d(TAG, "WebView Submit Button exists. Clicking on it…"); + webViewSubmitButton.click(); + + webViewSubmitButton.waitUntilGone(TIMEOUT); + + final var webViewGrantAccessButton = mDevice.findObject(new UiSelector() + .instance(0) + .className(Button.class)); + Log.d(TAG, "Waiting for WebView Grant Access Button…"); + webViewGrantAccessButton.waitForExists(TIMEOUT); + Log.d(TAG, "WebView Grant Access Button exists. Clicking on it…"); + webViewGrantAccessButton.click(); + + webView.waitUntilGone(TIMEOUT); + + mDevice.waitForIdle(TIMEOUT); + + Log.d(TAG, "Wait for Nextcloud files app…"); + Thread.sleep(3_000); + Log.d(TAG, "Finishing setup…"); + } + + @Test + public void test_01_importAccountIntoSampleApp() throws UiObjectNotFoundException, InterruptedException { + Log.i(TAG, "Import account into sample app"); + launch(APP_SAMPLE); + final var WAIT = 3_000; + + final var accountButton = mDevice.findObject(new UiSelector() + .instance(0) + .className(Button.class)); + accountButton.waitForExists(TIMEOUT); + accountButton.click(); + + mDevice.waitForWindowUpdate(null, TIMEOUT); + + final var radioAccount = mDevice.findObject(new UiSelector() + .clickable(true) + .instance(0)); + radioAccount.waitForExists(TIMEOUT); + radioAccount.click(); + + Thread.sleep(WAIT); + + final var okButton = mDevice.findObject(new UiSelector() + .textContains("OK")); + Log.d(TAG, "Waiting for OK Button…"); + okButton.waitForExists(TIMEOUT); + Thread.sleep(WAIT); + Log.d(TAG, "OK Button exists. Clicking on it…"); + okButton.click(); + Log.d(TAG, "OK Button clicked"); + + Thread.sleep(WAIT); + + final var allowButton = mDevice.findObject(new UiSelector() + .instance(1) + .className(Button.class)); + Log.d(TAG, "Waiting for Allow Button…"); + allowButton.waitForExists(TIMEOUT); + Log.d(TAG, "Allow Button exists. Clicking on it…"); + allowButton.click(); + Log.d(TAG, "Allow Button clicked"); + + Log.d(TAG, "Waiting for finished import…"); + final var welcomeText = mDevice.findObject(new UiSelector().description("Filter")); + welcomeText.waitForExists(TIMEOUT); + Log.d(TAG, "Import finished."); + + Log.i(TAG, "Verify successful import…"); + final var expectedToContain = CONFIG_DISPLAY_NAME + " on Nextcloud"; + final var result = mDevice.findObject(new UiSelector().textContains(expectedToContain)); + result.waitForExists(TIMEOUT); + Log.i(TAG, "Expected UI to display '" + expectedToContain + "'. Found: '" + result.getText() + "'."); + } + + private void launch(@NonNull String packageName) { + Log.d(TAG, "Launching " + packageName); + mDevice.pressHome(); + final var context = getInstrumentation().getContext(); + context.startActivity(context + .getPackageManager() + .getLaunchIntentForPackage(packageName) + .addFlags(Intent.FLAG_ACTIVITY_CLEAR_TASK)); + mDevice.wait(hasObject(By.pkg(packageName).depth(0)), TIMEOUT); + } +} diff --git a/sample/src/androidTest/java/com/nextcloud/android/sso/sample/ExampleInstrumentedTest.java b/sample/src/androidTest/java/com/nextcloud/android/sso/sample/ExampleInstrumentedTest.java deleted file mode 100644 index 5bb98ff0..00000000 --- a/sample/src/androidTest/java/com/nextcloud/android/sso/sample/ExampleInstrumentedTest.java +++ /dev/null @@ -1,33 +0,0 @@ -/* - * Nextcloud Android SingleSignOn Library - * - * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-FileCopyrightText: 2021 Stefan Niedermann - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.android.sso.sample; - -import static org.junit.Assert.assertEquals; - -import android.content.Context; - -import androidx.test.ext.junit.runners.AndroidJUnit4; -import androidx.test.platform.app.InstrumentationRegistry; - -import org.junit.Test; -import org.junit.runner.RunWith; - -/** - * Instrumented test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - assertEquals("com.nextcloud.android.sso.sample", appContext.getPackageName()); - } -} \ No newline at end of file diff --git a/sample/src/test/java/com/nextcloud/android/sso/sample/ExampleUnitTest.java b/sample/src/test/java/com/nextcloud/android/sso/sample/ExampleUnitTest.java deleted file mode 100644 index dbc01426..00000000 --- a/sample/src/test/java/com/nextcloud/android/sso/sample/ExampleUnitTest.java +++ /dev/null @@ -1,24 +0,0 @@ -/* - * Nextcloud Android SingleSignOn Library - * - * SPDX-FileCopyrightText: 2018-2024 Nextcloud GmbH and Nextcloud contributors - * SPDX-FileCopyrightText: 2021 Stefan Niedermann - * SPDX-License-Identifier: GPL-3.0-or-later - */ -package com.nextcloud.android.sso.sample; - -import static org.junit.Assert.assertEquals; - -import org.junit.Test; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file