diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d6f6c1907..c946ba1c8 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -143,9 +143,73 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v3 with: - name: test-reports + name: test-results-unit-tests path: '**/build/reports/tests/' + instrumentation_test_debug: + runs-on: macos-latest + timeout-minutes: 120 + + strategy: + fail-fast: false + env: + TERM: dumb + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up JDK 17 + uses: actions/setup-java@v3 + with: + java-version: '17' + distribution: 'temurin' + - name: Change Gradle wrapper permissions + run: chmod +x ./gradlew + - name: Restore cache + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*') }} + restore-keys: | + ${{ runner.os }}-gradle- + - name: Run tests + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: 33 + arch: arm64-v8a + channel: stable + target: google_apis + profile: pixel_6 + avd-name: macOs-avd-google_apis-arm64-v8a-33 + script: ./.tools/run_instrumentation_tests.sh --log-file=logcat.txt --run-affected --affected-base-ref=$BASE_REF + env: + IDE_INSTRUMENTATION_SETUP_BUILDTOOLS: true + ANDROIDIDE_TEST: true + IDE_SIGNING_ALIAS: ${{ secrets.IDE_SIGNING_ALIAS }} + IDE_SIGNING_AUTH_PASS: ${{ secrets.IDE_SIGNING_AUTH_PASS }} + IDE_SIGNING_AUTH_USER: ${{ secrets.IDE_SIGNING_AUTH_USER }} + IDE_SIGNING_KEY_PASS: ${{ secrets.IDE_SIGNING_KEY_PASS }} + IDE_SIGNING_STORE_PASS: ${{ secrets.IDE_SIGNING_STORE_PASS }} + IDE_SIGNING_URL: ${{ secrets.IDE_SIGNING_URL }} + IDE_SIGNING_KEY_BIN: ${{ secrets.IDE_SIGNING_KEY_BIN }} + - name: Upload logs + if: always() + uses: actions/upload-artifact@v2 + with: + name: emulator-logs + path: logcat.txt + - name: Upload test results + if: always() + uses: actions/upload-artifact@v2 + with: + name: test-results-instrumentation-tests + path: | + **/build/reports/* + **/build/outputs/*/connected/* + unit_test_tooling_api: name: Build and test Tooling API runs-on: ubuntu-latest @@ -191,7 +255,7 @@ jobs: if: ${{ always() }} uses: actions/upload-artifact@v3 with: - name: tooling-api-test-reports + name: test-results-tooling-api-tests path: '**/build/reports/tests/' publish: diff --git a/.idea/androidTestResultsUserPreferences.xml b/.idea/androidTestResultsUserPreferences.xml index c235bf396..f77d50377 100644 --- a/.idea/androidTestResultsUserPreferences.xml +++ b/.idea/androidTestResultsUserPreferences.xml @@ -70,6 +70,19 @@ + + + + + + + diff --git a/.tools/run_instrumentation_tests.sh b/.tools/run_instrumentation_tests.sh new file mode 100644 index 000000000..09708a111 --- /dev/null +++ b/.tools/run_instrumentation_tests.sh @@ -0,0 +1,129 @@ +#!/bin/bash + +# +# This file is part of AndroidIDE. +# +# AndroidIDE is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# AndroidIDE is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with AndroidIDE. If not, see . +# + +# Fail on error and print out commands +set -ex + +# By default we don't shard +SHARD_COUNT=0 +SHARD_INDEX=0 +# By default we don't log +LOG_FILE="" +# By default we run tests on device +DEVICE=true + +# The task for installing the tools in the emulator +INSTALLER_TEST_TASK_NAME=":app:connectedArm64-v8aDebugAndroidTest" +# Fully qualified name of the class which install installs the build tools +INSTALLER_TEST_CLASS_NAME="com.itsaky.androidide.BuildToolsInstallerTest" + +# Common Gradle invocation arguments +GRADLE_INVOCATION_ARGS="--scan --continue --no-configuration-cache --stacktrace" + +# Parse parameters +for i in "$@"; do + case $i in + --shard-count=*) + SHARD_COUNT="${i#*=}" + shift + ;; + --unit-tests) + DEVICE=false + shift + ;; + --shard-index=*) + SHARD_INDEX="${i#*=}" + shift + ;; + --log-file=*) + LOG_FILE="${i#*=}" + shift + ;; + --run-affected) + RUN_AFFECTED=true + shift + ;; + --run-flaky-tests) + RUN_FLAKY=true + shift + ;; + --affected-base-ref=*) + BASE_REF="${i#*=}" + shift + ;; + *) + echo "Unknown option" + exit 1 + ;; + esac +done + +# Start logcat if we have a file to log to +if [[ -n "$LOG_FILE" ]]; then + adb logcat >"$LOG_FILE" & +fi + +FILTER_OPTS="" +# Filter out flaky tests if we're not set to run them +if [[ -z "$RUN_FLAKY" ]]; then + FILTER_OPTS="$FILTER_OPTS -Pandroid.testInstrumentationRunnerArguments.notAnnotation=androidx.test.filters.FlakyTest" +fi + +# If we're set to only run affected test, update the Gradle task +if [[ -n "$RUN_AFFECTED" ]]; then + if [ "$DEVICE" = true ]; then + TASK="runAffectedAndroidTests" + else + TASK="runAffectedUnitTests" + fi + TASK="$TASK -Paffected_module_detector.enable" + + # If we have a base branch set, add the Gradle property + if [[ -n "$BASE_REF" ]]; then + TASK="$TASK -Paffected_base_ref=$BASE_REF" + fi +fi + +# If we don't have a task yet, use the defaults +if [[ -z "$TASK" ]]; then + if [ "$DEVICE" = true ]; then + TASK="connectedCheck" + else + TASK="testDebug" + fi +fi + +SHARD_OPTS="" +if [ "$SHARD_COUNT" -gt "0" ]; then + # If we have a shard count value, create the necessary Gradle property args. + # We assume that SHARD_INDEX has been set too + SHARD_OPTS="$SHARD_OPTS -Pandroid.testInstrumentationRunnerArguments.numShards=$SHARD_COUNT" + SHARD_OPTS="$SHARD_OPTS -Pandroid.testInstrumentationRunnerArguments.shardIndex=$SHARD_INDEX" +fi + +# Run the build tools installer test +# shellcheck disable=SC2086 +./gradlew \ + $GRADLE_INVOCATION_ARGS \ + $INSTALLER_TEST_TASK_NAME \ + -Pandroid.testInstrumentationRunnerArguments.class=$INSTALLER_TEST_CLASS_NAME + +# Run the actual tests +# shellcheck disable=SC2086 +./gradlew $GRADLE_INVOCATION_ARGS $TASK $FILTER_OPTS $SHARD_OPTS \ No newline at end of file diff --git a/app/src/androidTest/java/com/itsaky/androidide/BuildToolsInstallerTest.kt b/app/src/androidTest/java/com/itsaky/androidide/BuildToolsInstallerTest.kt new file mode 100644 index 000000000..c1a14a766 --- /dev/null +++ b/app/src/androidTest/java/com/itsaky/androidide/BuildToolsInstallerTest.kt @@ -0,0 +1,103 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide + +import android.util.Log +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.R.string +import com.itsaky.androidide.shell.executeProcessAsync +import com.itsaky.androidide.testing.android.clickAndWaitForWindowUpdate +import com.itsaky.androidide.testing.android.findObjectWithText +import com.itsaky.androidide.testing.android.hasObjectWithText +import com.itsaky.androidide.testing.android.launchAndroidIDE +import com.itsaky.androidide.utils.Environment +import org.junit.Test +import org.junit.runner.RunWith + +/** + * A special test case which is used to install the build tools on a test device. This is used to + * setup the test device before running further tests. + * + * This class should not define any other tests. + * + * @author Akash Yadav + */ +@RunWith(AndroidJUnit4::class) +class BuildToolsInstallerTest { + + companion object { + private const val TAG = "BuildToolsInstallerTest" + } + + @Test + fun setupDevice() { + if (!System.getenv("IDE_INSTRUMENTATION_SETUP_BUILDTOOLS").toBoolean()) { + return + } + + val device = launchAndroidIDE() + + // Allow permissions + while (true) { + val obj = device.findObjectWithText("Allow") + obj?.clickAndWaitForWindowUpdate(device) ?: break + } + + // Dismiss the 'AndroidIDE statistics' dialog + if (device.hasObjectWithText(string.title_androidide_statistics) + && device.hasObjectWithText(string.msg_androidide_statistics) + ) { + device.findObjectWithText(string.btn_opt_in).clickAndWaitForWindowUpdate(device) + } + + // The 'Warning' dialog will be shown, no need to dismiss it. + + // Start the installation process + execProcAndLogOutput("pkg upd") + execProcAndLogOutput("yes | pkg upg") // do not use -y + execProcAndLogOutput("yes | idesetup -c") + + // Check if the tools were installed + assertThat(Environment.JAVA.exists()).isTrue() + assertThat(Environment.ANDROID_HOME.exists()).isTrue() + + Thread.sleep(5000L) + } + + private fun execProcAndLogOutput(cmd: String) { + val process = executeProcessAsync { + redirectErrorStream = true + workingDirectory = null + environment = Environment.getEnvironment() + command = listOf(Environment.SHELL.absolutePath, "-c", cmd).also { + Log.d(TAG, "setupDevice: Full command : ${it.joinToString(separator = " ")}") + } + } + + logOutput(cmd, process) + + process.waitFor() + } + + private fun logOutput(cmd: String, process: Process) { + process.inputStream.bufferedReader().forEachLine { + Log.d(TAG, "setupDevice: '${cmd}': $it") + } + } +} \ No newline at end of file diff --git a/testing/android/src/main/java/androidx/test/uiautomator/UiDeviceAccessor.java b/testing/android/src/main/java/androidx/test/uiautomator/UiDeviceAccessor.java new file mode 100644 index 000000000..e4484d593 --- /dev/null +++ b/testing/android/src/main/java/androidx/test/uiautomator/UiDeviceAccessor.java @@ -0,0 +1,40 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package androidx.test.uiautomator; + +import android.app.UiAutomation; + +/** + * @author Akash Yadav + */ +public class UiDeviceAccessor { + + /** + * Get the {@link UiAutomation} instance from the given {@link UiDevice}. + */ + public static UiAutomation getUiAutomation(UiDevice device) { + return device.getUiAutomation(); + } + + /** + * Get the {@link InteractionController} instance from the given {@link UiDevice}. + */ + public static InteractionController getInteractionController(UiDevice device) { + return device.getInteractionController(); + } +} diff --git a/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiAutomatorUtils.kt b/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiAutomatorUtils.kt index 145f626fb..d4884e7ea 100644 --- a/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiAutomatorUtils.kt +++ b/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiAutomatorUtils.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.testing.android import android.app.Activity +import android.app.UiAutomation import android.content.Intent import androidx.test.core.app.ApplicationProvider import androidx.test.platform.app.InstrumentationRegistry @@ -25,6 +26,7 @@ import androidx.test.runner.lifecycle.ActivityLifecycleMonitorRegistry import androidx.test.runner.lifecycle.Stage import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiDeviceAccessor import androidx.test.uiautomator.Until import com.google.common.truth.Truth.assertThat import com.itsaky.androidide.app.BaseApplication @@ -90,4 +92,10 @@ fun getActivityInStage(stage: Stage): T? { } return activity -} \ No newline at end of file +} + +/** + * @see UiDeviceAccessor.getUiAutomation + */ +val UiDevice.uiAutomation: UiAutomation + get() = UiDeviceAccessor.getUiAutomation(this) \ No newline at end of file diff --git a/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiDeviceUtils.kt b/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiDeviceUtils.kt index 93801e734..c9031b03c 100644 --- a/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiDeviceUtils.kt +++ b/testing/android/src/main/java/com/itsaky/androidide/testing/android/uiDeviceUtils.kt @@ -17,12 +17,18 @@ package com.itsaky.androidide.testing.android +import android.view.KeyCharacterMap +import android.view.KeyEvent import androidx.annotation.StringRes import androidx.test.uiautomator.By import androidx.test.uiautomator.UiDevice import androidx.test.uiautomator.UiObject2 import com.itsaky.androidide.buildinfo.BuildInfo +private val keyCharMap by lazy { + KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD) +} + /** * @see UiDevice.waitForWindowUpdate */ @@ -51,4 +57,17 @@ fun UiDevice.findObjectWithText(text: String): UiObject2? = * @see UiDevice.findObjectWithText */ fun UiDevice.findObjectWithText(@StringRes text: Int): UiObject2? = - findObject(By.text(stringRes(text))) \ No newline at end of file + findObject(By.text(stringRes(text))) + +/** + * Sends key events to this [UiDevice] to type the given [text]. + */ +fun UiDevice.sendKeyEvents(text: String) { + waitForIdle() + val keyEvents = keyCharMap.getEvents(text.toCharArray()) + for (keyEvent in keyEvents) { + uiAutomation.injectInputEvent(keyEvent, true) +// pressKeyCode(keyEvent.keyCode, keyEvent.metaState) + waitForIdle() + } +} \ No newline at end of file