diff --git a/.github/workflows/scripts-android.yml b/.github/workflows/scripts-android.yml index 6f19f272ee..ca97aaf07a 100644 --- a/.github/workflows/scripts-android.yml +++ b/.github/workflows/scripts-android.yml @@ -9,8 +9,10 @@ name: Test Android build scripts - 'scripts/build-android-port.sh' - 'scripts/build-android-app.sh' - 'scripts/run-android-instrumentation-tests.sh' + - 'scripts/generate-android-coverage-report.sh' - 'scripts/android/lib/**/*.java' - 'scripts/android/tests/**/*.java' + - 'scripts/device-runner-app/**/*.java' - 'scripts/android/screenshots/**' - '!scripts/android/screenshots/**/*.md' - 'scripts/templates/**' @@ -33,8 +35,10 @@ name: Test Android build scripts - 'scripts/build-android-port.sh' - 'scripts/build-android-app.sh' - 'scripts/run-android-instrumentation-tests.sh' + - 'scripts/generate-android-coverage-report.sh' - 'scripts/android/lib/**/*.java' - 'scripts/android/tests/**/*.java' + - 'scripts/device-runner-app/**/*.java' - 'scripts/android/screenshots/**' - '!scripts/android/screenshots/**/*.md' - 'scripts/templates/**' @@ -96,6 +100,7 @@ jobs: target: google_apis script: | ./scripts/run-android-instrumentation-tests.sh "${{ steps.build-android-app.outputs.gradle_project_dir }}" + ./scripts/generate-android-coverage-report.sh "${{ steps.build-android-app.outputs.gradle_project_dir }}" - name: Upload emulator screenshot if: always() # still collect it if tests fail uses: actions/upload-artifact@v4 @@ -105,3 +110,12 @@ jobs: if-no-files-found: warn retention-days: 14 compression-level: 6 + - name: Upload Android Jacoco coverage report + if: always() + uses: actions/upload-artifact@v4 + with: + name: android-jacoco-coverage + path: artifacts/android-coverage-report + if-no-files-found: warn + retention-days: 14 + compression-level: 6 diff --git a/scripts/android/lib/PatchGradleFiles.java b/scripts/android/lib/PatchGradleFiles.java index 549da1dbf5..3bf2ef2643 100644 --- a/scripts/android/lib/PatchGradleFiles.java +++ b/scripts/android/lib/PatchGradleFiles.java @@ -106,6 +106,10 @@ private static boolean patchAppBuildGradle(Path path, int compileSdk, int target content = r.content(); changed |= r.changed(); + r = ensureJacocoConfiguration(content); + content = r.content(); + changed |= r.changed(); + if (changed) { Files.writeString(path, ensureTrailingNewline(content), StandardCharsets.UTF_8); } @@ -247,6 +251,88 @@ private static Result ensureTestDependencies(String content) { return new Result(content + block, true); } + private static Result ensureJacocoConfiguration(String content) { + if (content.contains("jacocoAndroidReport")) { + return new Result(content, false); + } + + String jacocoBlock = """ +apply plugin: 'jacoco' + +android { + buildTypes { + debug { + testCoverageEnabled true + } + } +} + +jacoco { + toolVersion = "0.8.11" +} + + tasks.register("jacocoAndroidReport", JacocoReport) { + group = "verification" + description = "Generates Jacoco coverage report for the debug variant" + outputs.upToDateWhen { false } + + reports { + xml.required = true + html.required = true + html.outputLocation = layout.buildDirectory.dir("reports/jacoco/jacocoAndroidReport/html") + } + + def coverageFiles = fileTree(dir: "$buildDir", includes: [ + "outputs/code_coverage/**/*coverage.ec", + "jacoco/*.exec", + "outputs/unit_test_code_coverage/**/*coverage.ec", + "**/*.ec", + "**/*.exec" + ]) + + def excludes = [ + '**/R.class', + '**/R$*.class', + '**/BuildConfig.*', + '**/Manifest*.*', + '**/*Test*.*', + '**/androidx/**/*', + '**/com/google/**/*' + ] + + def javaClasses = fileTree(dir: "$buildDir/intermediates/javac/debug/classes", exclude: excludes) + def kotlinClasses = fileTree(dir: "$buildDir/tmp/kotlin-classes/debug", exclude: excludes) + def aarMainJar = file("$buildDir/intermediates/aar_main_jar/debug/classes.jar") + def aarTrees = aarMainJar.exists() ? [zipTree(aarMainJar)] : [] + def runtimeJars = configurations.debugRuntimeClasspath.filter { it.name.endsWith('.jar') }.collect { zipTree(it) } + + classDirectories.setFrom(files(javaClasses, kotlinClasses, aarTrees, runtimeJars).asFileTree.matching { + include 'com/codename1/impl/android/**' + }) + + sourceDirectories.setFrom(files("src/main/java")) + + executionData.setFrom(coverageFiles) + + doFirst { + def existing = coverageFiles.files.findAll { it.exists() } + if (existing.isEmpty()) { + throw new GradleException("No Jacoco coverage data found. Ensure connectedDebugAndroidTest runs with coverage enabled.") + } + logger.lifecycle("Jacoco coverage inputs: ${existing}") + } +} + +afterEvaluate { + tasks.matching { it.name == "connectedDebugAndroidTest" }.configureEach { + finalizedBy(tasks.named("jacocoAndroidReport")) + } +} +""".stripTrailing(); + + return new Result(ensureTrailingNewline(content) + "\n" + jacocoBlock + "\n", true); + } + private static String ensureTrailingNewline(String content) { return content.endsWith("\n") ? content : content + "\n"; } diff --git a/scripts/build-android-app.sh b/scripts/build-android-app.sh index d7be421dff..57e4009e44 100755 --- a/scripts/build-android-app.sh +++ b/scripts/build-android-app.sh @@ -270,6 +270,22 @@ fi ba_log "Normalizing Android Gradle project in $GRADLE_PROJECT_DIR" +# --- Install Android instrumentation harness for coverage --- +ANDROID_TEST_SOURCE_DIR="$SCRIPT_DIR/device-runner-app/androidTest" +ANDROID_TEST_ROOT="$GRADLE_PROJECT_DIR/app/src/androidTest" +ANDROID_TEST_JAVA_DIR="$ANDROID_TEST_ROOT/java/${PACKAGE_PATH}" +if [ -d "$ANDROID_TEST_ROOT" ]; then + ba_log "Removing template Android instrumentation tests from $ANDROID_TEST_ROOT" + rm -rf "$ANDROID_TEST_ROOT" +fi +mkdir -p "$ANDROID_TEST_JAVA_DIR" +if [ ! -d "$ANDROID_TEST_SOURCE_DIR" ]; then + ba_log "Android instrumentation test sources not found: $ANDROID_TEST_SOURCE_DIR" >&2 + exit 1 +fi +cp "$ANDROID_TEST_SOURCE_DIR"/*.java "$ANDROID_TEST_JAVA_DIR"/ +ba_log "Installed Android instrumentation tests in $ANDROID_TEST_JAVA_DIR" + # Ensure AndroidX flags in gradle.properties # --- BEGIN: robust Gradle patch for AndroidX tests --- GRADLE_PROPS="$GRADLE_PROJECT_DIR/gradle.properties" diff --git a/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java b/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java new file mode 100644 index 0000000000..b884e7f64a --- /dev/null +++ b/scripts/device-runner-app/androidTest/DeviceRunnerInstrumentationTest.java @@ -0,0 +1,67 @@ +package com.codenameone.examples.hellocodenameone; + +import android.app.UiAutomation; +import android.content.Context; +import android.content.Intent; +import android.os.ParcelFileDescriptor; +import android.util.Log; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.io.BufferedReader; +import java.io.FileInputStream; +import java.io.InputStreamReader; + +import static org.junit.Assert.assertNotNull; + +@RunWith(AndroidJUnit4.class) +public class DeviceRunnerInstrumentationTest { + private static final String TAG = "DeviceRunnerTest"; + + @Test + public void launchMainActivityAndWaitForDeviceRunner() throws Exception { + Context context = ApplicationProvider.getApplicationContext(); + Intent intent = context.getPackageManager().getLaunchIntentForPackage(context.getPackageName()); + assertNotNull("Launch intent not found for package " + context.getPackageName(), intent); + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + context.startActivity(intent); + + boolean finished = waitForDeviceRunner(); + if (!finished) { + Log.w(TAG, "DeviceRunner did not emit completion marker; proceeding without hard failure"); + } + } + + private boolean waitForDeviceRunner() throws Exception { + final long timeoutMs = 300_000L; + final String endMarker = "CN1SS:SUITE:FINISHED"; + + long deadline = System.currentTimeMillis() + timeoutMs; + UiAutomation automation = InstrumentationRegistry.getInstrumentation().getUiAutomation(); + ParcelFileDescriptor pfd = automation.executeShellCommand("logcat -v brief"); + try (FileInputStream fis = new FileInputStream(pfd.getFileDescriptor()); + BufferedReader reader = new BufferedReader(new InputStreamReader(fis))) { + String line; + while (System.currentTimeMillis() < deadline) { + if (reader.ready() && (line = reader.readLine()) != null) { + if (line.contains(endMarker)) { + return true; + } + } else { + Thread.sleep(200); + } + } + } finally { + try { + pfd.close(); + } catch (Exception ignored) { + } + } + return false; + } +} diff --git a/scripts/generate-android-coverage-report.sh b/scripts/generate-android-coverage-report.sh new file mode 100755 index 0000000000..c5c276290b --- /dev/null +++ b/scripts/generate-android-coverage-report.sh @@ -0,0 +1,63 @@ +#!/usr/bin/env bash +# Generate a Jacoco coverage report for the Android sample app +set -euo pipefail + +cov_log() { echo "[generate-android-coverage] $1"; } + +if [ $# -lt 1 ]; then + cov_log "Usage: $0 " >&2 + exit 2 +fi + +GRADLE_PROJECT_DIR="$1" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +cd "$REPO_ROOT" + +TMPDIR="${TMPDIR:-/tmp}"; TMPDIR="${TMPDIR%/}" +DOWNLOAD_DIR="${TMPDIR}/codenameone-tools" +ENV_DIR="$DOWNLOAD_DIR/tools" +ENV_FILE="$ENV_DIR/env.sh" + +ARTIFACTS_DIR="${ARTIFACTS_DIR:-${GITHUB_WORKSPACE:-$REPO_ROOT}/artifacts}" +mkdir -p "$ARTIFACTS_DIR" +REPORT_DEST_DIR="$ARTIFACTS_DIR/android-coverage-report" + +cov_log "Loading workspace environment from $ENV_FILE" +if [ -f "$ENV_FILE" ]; then + # shellcheck disable=SC1090 + source "$ENV_FILE" + cov_log "Loaded environment: JAVA_HOME=${JAVA_HOME:-} JAVA17_HOME=${JAVA17_HOME:-}" +else + cov_log "Workspace tools not found. Run scripts/setup-workspace.sh before this script." >&2 + exit 1 +fi + +if [ ! -d "$GRADLE_PROJECT_DIR" ]; then + cov_log "Gradle project directory not found: $GRADLE_PROJECT_DIR" >&2 + exit 3 +fi + +if [ ! -x "$GRADLE_PROJECT_DIR/gradlew" ]; then + cov_log "Gradle wrapper missing at $GRADLE_PROJECT_DIR/gradlew" >&2 + exit 3 +fi + +COVERAGE_SCAN=$(find "$GRADLE_PROJECT_DIR/app/build" -type f \( -name "*.ec" -o -name "*.exec" \) 2>/dev/null | sed 's/^/[generate-android-coverage] coverage-file: /') +if [ -n "$COVERAGE_SCAN" ]; then + echo "$COVERAGE_SCAN" +else + cov_log "No coverage data files detected under $GRADLE_PROJECT_DIR/app/build" +fi + +REPORT_SOURCE_DIR="$GRADLE_PROJECT_DIR/app/build/reports/jacoco/jacocoAndroidReport" +if [ ! -d "$REPORT_SOURCE_DIR" ]; then + cov_log "Coverage report directory not found: $REPORT_SOURCE_DIR" >&2 + exit 4 +fi + +rm -rf "$REPORT_DEST_DIR" +mkdir -p "$REPORT_DEST_DIR" +cp -R "$REPORT_SOURCE_DIR"/ "${REPORT_DEST_DIR}"/ + +cov_log "Copied Jacoco coverage report to $REPORT_DEST_DIR" diff --git a/scripts/run-android-instrumentation-tests.sh b/scripts/run-android-instrumentation-tests.sh index bac4b3a7bc..2e81e9bf9b 100755 --- a/scripts/run-android-instrumentation-tests.sh +++ b/scripts/run-android-instrumentation-tests.sh @@ -70,17 +70,6 @@ cn1ss_setup "$JAVA17_BIN" "$CN1SS_HELPER_SOURCE_DIR" [ -x "$GRADLE_PROJECT_DIR/gradlew" ] || chmod +x "$GRADLE_PROJECT_DIR/gradlew" # ---- Prepare app + emulator state ----------------------------------------- - -APK_PATH="${2:-}" -if [ -z "$APK_PATH" ]; then - APK_PATH="$(find "$GRADLE_PROJECT_DIR" -type f -path '*/outputs/apk/debug/*.apk' | head -n 1 || true)" -fi -if [ -z "$APK_PATH" ] || [ ! -f "$APK_PATH" ]; then - ra_log "FATAL: Unable to locate debug APK under $GRADLE_PROJECT_DIR" >&2 - exit 10 -fi -ra_log "Using APK: $APK_PATH" - MANIFEST="$GRADLE_PROJECT_DIR/app/src/main/AndroidManifest.xml" if [ ! -f "$MANIFEST" ]; then ra_log "FATAL: AndroidManifest.xml not found at $MANIFEST" >&2 @@ -104,18 +93,6 @@ ADB_BIN="$(command -v adb)" ra_log "ADB connected devices:" "$ADB_BIN" devices -l | sed 's/^/[run-android-instrumentation-tests] /' -ra_log "Installing APK onto device" -"$ADB_BIN" shell am force-stop "$PACKAGE_NAME" >/dev/null 2>&1 || true -"$ADB_BIN" uninstall "$PACKAGE_NAME" >/dev/null 2>&1 || true -if ! "$ADB_BIN" install -r "$APK_PATH"; then - ra_log "adb install failed; retrying after explicit uninstall" - "$ADB_BIN" uninstall "$PACKAGE_NAME" >/dev/null 2>&1 || true - if ! "$ADB_BIN" install "$APK_PATH"; then - ra_log "FATAL: adb install failed after retry" - exit 10 - fi -fi - ra_log "Clearing logcat buffer" "$ADB_BIN" logcat -c || true @@ -134,24 +111,21 @@ ra_log "Capturing device logcat to $TEST_LOG" LOGCAT_PID=$! sleep 2 -ra_log "Launching Codename One DeviceRunner" -"$ADB_BIN" shell pm clear "$PACKAGE_NAME" >/dev/null 2>&1 || true -if ! "$ADB_BIN" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1 >/dev/null 2>&1; then - ra_log "monkey launch failed; attempting am start fallback" - MAIN_ACTIVITY="$("$ADB_BIN" shell cmd package resolve-activity --brief "$PACKAGE_NAME" 2>/dev/null | head -n 1 | tr -d '\r' | sed 's/ .*//')" - if [[ "$MAIN_ACTIVITY" == */* ]]; then - if ! "$ADB_BIN" shell am start -n "$MAIN_ACTIVITY" >/dev/null 2>&1; then - ra_log "FATAL: Failed to start application via am start" - exit 10 - fi - else - ra_log "FATAL: Unable to determine launchable activity" - exit 10 - fi +GRADLEW="$GRADLE_PROJECT_DIR/gradlew" +[ -x "$GRADLEW" ] || chmod +x "$GRADLEW" +GRADLE_CMD=("$GRADLEW" --no-daemon connectedDebugAndroidTest) + +ra_log "Executing connectedDebugAndroidTest via Gradle" +if ! ( + cd "$GRADLE_PROJECT_DIR" + JAVA_HOME="$JAVA17_HOME" "${GRADLE_CMD[@]}" +); then + ra_log "FATAL: connectedDebugAndroidTest failed" + exit 10 fi END_MARKER="CN1SS:SUITE:FINISHED" -TIMEOUT_SECONDS=300 +TIMEOUT_SECONDS=60 START_TIME="$(date +%s)" ra_log "Waiting for DeviceRunner completion marker ($END_MARKER)" while true; do