Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit f08dd1f9 authored by Romain Hunault's avatar Romain Hunault 🚴🏻
Browse files

feat(test): add JaCoCo coverage workflow for Maestro debug runs

parent a2c35abe
Loading
Loading
Loading
Loading
+41 −0
Original line number Diff line number Diff line
@@ -107,7 +107,10 @@ android {

    buildTypes {
        debug {
            applicationIdSuffix ".debug"
            signingConfig = signingConfigs.platformConfig
            // Offline instrumentation used to collect coverage from on-device runs (e.g. Maestro).
            enableAndroidTestCoverage = true
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
        release {
@@ -184,6 +187,44 @@ android.applicationVariants.configureEach { variant ->
    }
}

tasks.register("jacocoDebugMaestroReport", JacocoReport) {
    group = "verification"
    description = "Generates Jacoco coverage report from Maestro on-device run for debug variant."

    reports {
        xml.required = true
        html.required = true
    }

    def maestroCoverageFile = providers.gradleProperty("maestroCoverageFile")
            .orElse("${buildDir}/outputs/code_coverage/maestro/coverage.ec")

    def javaClasses = fileTree("${buildDir}/intermediates/javac/debug/classes") {
        exclude jacocoFileFilter
    }
    def kotlinClasses = fileTree("${buildDir}/tmp/kotlin-classes/debug") {
        exclude jacocoFileFilter
    }
    def mainSourceSet = android.sourceSets.findByName("main")
    def sourceDirs = []
    sourceDirs.addAll(mainSourceSet.java.srcDirs)
    if (mainSourceSet.hasProperty('kotlin')) {
        sourceDirs.addAll(mainSourceSet.kotlin.srcDirs)
    }

    classDirectories.from = files(javaClasses, kotlinClasses)
    sourceDirectories.from = files(sourceDirs)

    executionData.from = files(maestroCoverageFile)

    doFirst {
        def coverageFile = file(maestroCoverageFile.get())
        if (!coverageFile.exists()) {
            throw new GradleException("Maestro coverage file not found: ${coverageFile}. Run scripts/maestro_coverage.sh first or pass -PmaestroCoverageFile=<path>.")
        }
    }
}

allOpen {
    // allows mocking for classes w/o directly opening them for release builds
    annotation 'foundation.e.apps.OpenClass'
+13 −0
Original line number Diff line number Diff line
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <application>
        <receiver
            android:name="foundation.e.apps.debug.JacocoDumpReceiver"
            android:enabled="true"
            android:exported="true">
            <intent-filter>
                <action android:name="${applicationId}.action.DUMP_COVERAGE" />
            </intent-filter>
        </receiver>
    </application>
</manifest>
+31 −0
Original line number Diff line number Diff line
package foundation.e.apps.debug

import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import java.io.File

class JacocoDumpReceiver : BroadcastReceiver() {
    override fun onReceive(context: Context?, intent: Intent?) {
        try {
            val nonNullContext = context ?: return
            val rtClass = Class.forName("org.jacoco.agent.rt.RT")
            val getAgent = rtClass.getMethod("getAgent")
            val agent = getAgent.invoke(null)
            val getExecutionData = agent.javaClass.getMethod("getExecutionData", Boolean::class.javaPrimitiveType)
            val executionData = getExecutionData.invoke(agent, false) as ByteArray
            val outFile = File(nonNullContext.filesDir, "coverage.ec")
            outFile.outputStream().use { stream ->
                stream.write(executionData)
            }
            Log.i(TAG, "JaCoCo execution data written to ${outFile.absolutePath}")
        } catch (throwable: Throwable) {
            Log.e(TAG, "Unable to dump JaCoCo data", throwable)
        }
    }

    companion object {
        private const val TAG = "JacocoDumpReceiver"
    }
}
+28 −0
Original line number Diff line number Diff line
@@ -86,3 +86,31 @@ maestro test -e APP_ID=foundation.e.apps \
  --output ../.tmp/maestro-home/reports/scenarios-junit.xml \
  test/scenarios
```

## JaCoCo coverage with Maestro

The project exposes a dedicated report task: `:app:jacocoDebugMaestroReport`.

Use the helper script from the repository root to run Maestro and generate
coverage in one go:

```shell
./scripts/maestro_coverage.sh test -e APP_ID=foundation.e.apps maestro/test/scenarios
```

Report location:

- Coverage data: `app/build/outputs/code_coverage/maestro/coverage.ec`
- HTML report: `app/build/reports/jacoco/jacocoDebugMaestroReport/html/index.html`
- XML report: `app/build/reports/jacoco/jacocoDebugMaestroReport/jacocoDebugMaestroReport.xml`

Notes:

- `APP_ID` defaults to `foundation.e.apps.debug`.
- Set `ANDROID_SERIAL=<device-id>` when multiple devices are connected.
- You can also call the report task manually:

```shell
./gradlew :app:jacocoDebugMaestroReport \
  -PmaestroCoverageFile=app/build/outputs/code_coverage/maestro/coverage.ec
```
+84 −0
Original line number Diff line number Diff line
#!/usr/bin/env bash
set -euo pipefail

DEFAULT_APP_ID="foundation.e.apps.debug"
APP_ID="${APP_ID:-${DEFAULT_APP_ID}}"
MODULE="app"
ANDROID_SERIAL_OPT=""

if [[ -n "${ANDROID_SERIAL:-}" ]]; then
  ANDROID_SERIAL_OPT="-s ${ANDROID_SERIAL}"
fi

if [[ $# -lt 1 ]]; then
  echo "Usage:"
  echo "  APP_ID=<applicationId> $0 <maestro args...>"
  echo
  echo "Example:"
  echo "  APP_ID=${DEFAULT_APP_ID} $0 test -e APP_ID=${DEFAULT_APP_ID} maestro/test/scenarios"
  exit 1
fi

ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
OUT_DIR="${ROOT_DIR}/${MODULE}/build/outputs/code_coverage/maestro"
OUT_FILE="${OUT_DIR}/coverage.ec"
APK_FILE="${ROOT_DIR}/${MODULE}/build/outputs/apk/debug/AppLounge_debug.apk"

echo "==> Using APP_ID=${APP_ID}"

mkdir -p "${OUT_DIR}"
rm -f "${OUT_FILE}"

echo "==> Building ${MODULE}:debug with coverage instrumentation"
(
  cd "${ROOT_DIR}"
  ./gradlew ":${MODULE}:assembleDebug"
)

if [[ ! -f "${APK_FILE}" ]]; then
  echo "APK not found: ${APK_FILE}"
  exit 1
fi

echo "==> Installing debug APK on device"
adb ${ANDROID_SERIAL_OPT} install -r "${APK_FILE}" >/dev/null

echo "==> Clearing previous coverage file in app sandbox"
adb ${ANDROID_SERIAL_OPT} shell run-as "${APP_ID}" rm -f files/coverage.ec >/dev/null 2>&1 || true

echo "==> Running Maestro command"
MAESTRO_EXIT_CODE=0
set +e
(
  cd "${ROOT_DIR}"
  maestro "$@"
)
MAESTRO_EXIT_CODE=$?
set -e

echo "==> Triggering JaCoCo dump broadcast"
adb ${ANDROID_SERIAL_OPT} shell am broadcast \
  -a "${APP_ID}.action.DUMP_COVERAGE" \
  -p "${APP_ID}" >/dev/null

echo "==> Stopping app to flush JaCoCo runtime data"
adb ${ANDROID_SERIAL_OPT} shell am force-stop "${APP_ID}" >/dev/null

echo "==> Pulling coverage.ec from app sandbox"
adb ${ANDROID_SERIAL_OPT} exec-out run-as "${APP_ID}" cat files/coverage.ec > "${OUT_FILE}"

echo "==> Generating report"
(
  cd "${ROOT_DIR}"
  ./gradlew ":${MODULE}:jacocoDebugMaestroReport" \
    -PmaestroCoverageFile="${OUT_FILE}"
)

echo "Coverage artifact: ${OUT_FILE}"
echo "HTML report: ${ROOT_DIR}/${MODULE}/build/reports/jacoco/jacocoDebugMaestroReport/html/index.html"

if [[ ${MAESTRO_EXIT_CODE} -ne 0 ]]; then
  echo "Maestro exited with code ${MAESTRO_EXIT_CODE}"
fi

exit ${MAESTRO_EXIT_CODE}