From 4b99f17311483cb60c8b00ed7ac85f567ee432dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 19 May 2026 15:40:57 +0200 Subject: [PATCH 1/3] feat: add Android native code coverage support Add @react-native-harness/coverage-android package with JaCoCo offline instrumentation. A Gradle init script instruments Kotlin/Java classes at build time, and the coverage collector pulls .ec files from the device and generates lcov reports at test time. --- ANDROID_COVERAGE_GUIDE.md | 161 ++++++++ packages/config/src/types.ts | 12 + .../android/src/main/AndroidManifest.xml | 9 + .../com/harness/coverage/CoverageHelper.kt | 64 +++ .../harness/coverage/CoverageInitProvider.kt | 20 + .../main/resources/jacoco-agent.properties | 1 + packages/coverage-android/package.json | 37 ++ .../scripts/harness-coverage-init.gradle | 174 ++++++++ .../scripts/resolve-coverage-modules.mjs | 5 + packages/coverage-android/src/index.ts | 1 + packages/coverage-android/tsconfig.json | 13 + packages/coverage-android/tsconfig.lib.json | 19 + packages/coverage-ios/tsconfig.json | 3 + packages/coverage-ios/tsconfig.lib.json | 7 +- packages/jest/src/harness-session.ts | 39 +- .../src/coverage-collector.ts | 380 ++++++++++++++++++ packages/platform-android/src/instance.ts | 23 ++ packages/platform-ios/src/instance.ts | 2 +- packages/platforms/src/types.ts | 3 +- tsconfig.json | 3 + 20 files changed, 961 insertions(+), 15 deletions(-) create mode 100644 ANDROID_COVERAGE_GUIDE.md create mode 100644 packages/coverage-android/android/src/main/AndroidManifest.xml create mode 100644 packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageHelper.kt create mode 100644 packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageInitProvider.kt create mode 100644 packages/coverage-android/android/src/main/resources/jacoco-agent.properties create mode 100644 packages/coverage-android/package.json create mode 100644 packages/coverage-android/scripts/harness-coverage-init.gradle create mode 100644 packages/coverage-android/scripts/resolve-coverage-modules.mjs create mode 100644 packages/coverage-android/src/index.ts create mode 100644 packages/coverage-android/tsconfig.json create mode 100644 packages/coverage-android/tsconfig.lib.json create mode 100644 packages/platform-android/src/coverage-collector.ts diff --git a/ANDROID_COVERAGE_GUIDE.md b/ANDROID_COVERAGE_GUIDE.md new file mode 100644 index 00000000..999c5c90 --- /dev/null +++ b/ANDROID_COVERAGE_GUIDE.md @@ -0,0 +1,161 @@ +# Android Native Coverage — Local Testing Guide + +How to test Android native (Kotlin/Java) code coverage collection with a local React Native library module. + +## Prerequisites + +- Android SDK with an emulator image +- Java 11+ (for JaCoCo CLI) +- A React Native library with an `android/` module and an example/playground app + +## 1. Install the coverage package + +From your library's example/playground app directory: + +```bash +# If using the harness monorepo locally (linked): +pnpm add @react-native-harness/coverage-android --workspace + +# Or from npm (once published): +npm install --save-dev @react-native-harness/coverage-android +``` + +## 2. Build the app with coverage instrumentation + +The init script handles everything — no changes to your `build.gradle` needed. + +```bash +cd android + +./gradlew assembleDebug \ + --init-script ../node_modules/@react-native-harness/coverage-android/scripts/harness-coverage-init.gradle \ + -PHarnessCoverageModules=:mylib + +cd .. +``` + +Replace `:mylib` with your library's Gradle module path (e.g. `:react-native-my-lib`, `:android`). You can instrument multiple modules: `-PHarnessCoverageModules=:moduleA,:moduleB`. + +### What the init script does + +- Adds JaCoCo offline instrumentation to the specified modules' compiled classes +- Injects `CoverageHelper` + `CoverageInitProvider` into the debug build +- Saves original (uninstrumented) class files + `jacococli.jar` to `/build/harness-coverage/` +- Adds `BuildConfig.COVERAGE_ENABLED = true` + +### Verify instrumentation worked + +```bash +javap -p android//build/tmp/kotlin-classes/debug/com/example/MyClass.class | grep jacoco +``` + +You should see `$jacocoInit` — that means JaCoCo probes are present. + +## 3. Configure harness + +In your `rn-harness.config.mjs`: + +```javascript +import { androidPlatform, androidEmulator } from '@react-native-harness/platform-android'; + +export default { + entryPoint: './index.js', + appRegistryComponentName: 'MyApp', + runners: [ + androidPlatform({ + name: 'android', + device: androidEmulator('Pixel_8_API_35'), + bundleId: 'com.example.myapp', + }), + ], + coverage: { + native: { + android: { + modules: [':mylib'], + }, + }, + }, +}; +``` + +The `modules` array must match the module paths you passed to the init script. + +## 4. Run tests with coverage + +```bash +npx react-native-harness --coverage --harnessRunner android +``` + +After tests complete, the harness will: + +1. Stop the app (triggers `am force-stop`) +2. Wait 2 seconds for the JaCoCo flush timer to write final data +3. Pull `.ec` files from the app's internal storage via `adb` +4. Merge them using `jacococli.jar` (from the build output) +5. Generate an XML report using the original (uninstrumented) class files +6. Convert to lcov format + +Output: `native-coverage.lcov` in the project root. + +## 5. View the report + +```bash +# Quick summary +grep -c "^DA:" native-coverage.lcov +# -> number of instrumented lines + +# Generate HTML (requires lcov tools) +genhtml native-coverage.lcov -o coverage-html +open coverage-html/index.html +``` + +## How it works + +### Build time + +The Gradle init script hooks into `compileDebugKotlin` (and `compileDebugJavaWithJavac` if present). After compilation: + +1. Copies original `.class` files to `build/harness-coverage/original-classes-kotlin/` (needed for reports since instrumented classes have different bytecode) +2. Runs JaCoCo's `InstrumentTask` to rewrite `.class` files with coverage probes +3. Copies `jacococli.jar` to `build/harness-coverage/` so it's available at report time without needing Gradle + +### Runtime + +`CoverageInitProvider` (a `ContentProvider`) bootstraps `CoverageHelper.setup()` before any Activity starts. The helper: + +- Writes coverage data to `context.filesDir/coverage-{pid}.ec` every 1 second via a daemon timer +- Also flushes on `onActivityStopped` + +Each app restart (the harness restarts per test suite) gets its own `.ec` file keyed by PID. + +### Collection + +The coverage collector pulls `.ec` files from the device by copying them to `/data/local/tmp/` via `adb shell run-as`, then using `adb pull` (which handles binary data correctly). It then uses `jacococli.jar` from the build output to merge and generate reports. + +## Troubleshooting + +### "Original class files not found" + +The build output wasn't found at test time. Make sure: +- You built with `--init-script` and the correct `-PHarnessCoverageModules` +- The `modules` in `rn-harness.config.mjs` match the Gradle module paths +- If build and test run on different machines, transfer the entire `/build/harness-coverage/` directory + +### "jacococli.jar not found" + +Same as above — the init script stashes `jacococli.jar` during the build. If it's missing, the build didn't use the init script. + +### "No .ec files found on device" + +The app didn't write coverage data. Check: +- Was the app built with the init script? (`javap -p ... | grep jacoco`) +- Did the app actually run? (check adb logcat for `HarnessCoverage` tag) +- Is `BuildConfig.COVERAGE_ENABLED` true? + +### 0% coverage on everything + +The `.ec` data doesn't match the class files. This happens when you rebuild without re-running tests, or vice versa. Always use matching build + test runs. + +### `EROFS` crash on startup + +Missing `jacoco-agent.properties` with `output=none`. The init script injects this automatically — if you see this error, the init script wasn't applied correctly. diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index fc0d5143..e2409c08 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -118,6 +118,18 @@ export const ConfigSchema = z ), }) .optional(), + android: z + .object({ + modules: z + .array(z.string()) + .min(1, 'At least one Gradle module path is required') + .describe( + 'Gradle module paths to instrument for native code coverage, ' + + 'e.g. [":android"]. The app must be built with the harness coverage ' + + 'init script to enable JaCoCo offline instrumentation.' + ), + }) + .optional(), }) .optional() .describe('Native code coverage configuration.'), diff --git a/packages/coverage-android/android/src/main/AndroidManifest.xml b/packages/coverage-android/android/src/main/AndroidManifest.xml new file mode 100644 index 00000000..6ad3cb4d --- /dev/null +++ b/packages/coverage-android/android/src/main/AndroidManifest.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageHelper.kt b/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageHelper.kt new file mode 100644 index 00000000..23d52423 --- /dev/null +++ b/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageHelper.kt @@ -0,0 +1,64 @@ +package com.harness.coverage + +import android.app.Activity +import android.app.Application +import android.content.Context +import android.os.Bundle +import android.util.Log +import java.io.File + +object CoverageHelper { + private const val TAG = "HarnessCoverage" + private var ecFile: File? = null + private var timer: java.util.Timer? = null + private var cachedAgent: Any? = null + + fun setup(context: Context) { + if (!BuildConfig.COVERAGE_ENABLED) return + + val agent = try { + Class.forName("org.jacoco.agent.rt.RT") + .getMethod("getAgent") + .invoke(null) + } catch (e: Exception) { + Log.w(TAG, "JaCoCo agent not available — was the app built with coverage?", e) + return + } + cachedAgent = agent + + val pid = android.os.Process.myPid() + ecFile = File(context.filesDir, "coverage-$pid.ec") + + val app = context.applicationContext as? Application + app?.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks { + override fun onActivityStopped(activity: Activity) = flush() + override fun onActivityCreated(a: Activity, b: Bundle?) {} + override fun onActivityStarted(a: Activity) {} + override fun onActivityResumed(a: Activity) {} + override fun onActivityPaused(a: Activity) {} + override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {} + override fun onActivityDestroyed(a: Activity) {} + }) + + timer = java.util.Timer("HarnessCoverageFlush", true).also { + it.scheduleAtFixedRate(object : java.util.TimerTask() { + override fun run() = flush() + }, 1000L, 1000L) + } + + Log.i(TAG, "pid=$pid, flushing to ${ecFile?.absolutePath}") + } + + fun flush() { + val file = ecFile ?: return + val agent = cachedAgent ?: return + try { + val bytes = agent.javaClass + .getMethod("getExecutionData", Boolean::class.javaPrimitiveType) + .invoke(agent, false) as ByteArray + file.writeBytes(bytes) + } catch (e: Exception) { + Log.w(TAG, "Failed to flush coverage data", e) + } + } +} diff --git a/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageInitProvider.kt b/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageInitProvider.kt new file mode 100644 index 00000000..59493a89 --- /dev/null +++ b/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageInitProvider.kt @@ -0,0 +1,20 @@ +package com.harness.coverage + +import android.content.ContentProvider +import android.content.ContentValues +import android.database.Cursor +import android.net.Uri + +class CoverageInitProvider : ContentProvider() { + override fun onCreate(): Boolean { + val ctx = context ?: return true + CoverageHelper.setup(ctx) + return true + } + + override fun query(u: Uri, p: Array?, s: String?, a: Array?, o: String?): Cursor? = null + override fun getType(uri: Uri): String? = null + override fun insert(uri: Uri, values: ContentValues?): Uri? = null + override fun delete(uri: Uri, selection: String?, selectionArgs: Array?): Int = 0 + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 +} diff --git a/packages/coverage-android/android/src/main/resources/jacoco-agent.properties b/packages/coverage-android/android/src/main/resources/jacoco-agent.properties new file mode 100644 index 00000000..52c4b253 --- /dev/null +++ b/packages/coverage-android/android/src/main/resources/jacoco-agent.properties @@ -0,0 +1 @@ +output=none diff --git a/packages/coverage-android/package.json b/packages/coverage-android/package.json new file mode 100644 index 00000000..990ea1ee --- /dev/null +++ b/packages/coverage-android/package.json @@ -0,0 +1,37 @@ +{ + "name": "@react-native-harness/coverage-android", + "description": "Native Android code coverage support for React Native Harness.", + "version": "1.1.0", + "type": "module", + "exports": { + "./package.json": "./package.json", + ".": { + "development": "./src/index.ts", + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "files": [ + "src", + "dist", + "android", + "scripts", + "!**/__tests__", + "!**/__fixtures__", + "!**/__mocks__", + "!**/.*" + ], + "peerDependencies": { + "react-native": "*" + }, + "dependencies": { + "tslib": "^2.3.0" + }, + "devDependencies": { + "react-native": "*" + }, + "license": "MIT", + "homepage": "https://github.com/callstackincubator/react-native-harness", + "author": "React Native Harness contributors" +} diff --git a/packages/coverage-android/scripts/harness-coverage-init.gradle b/packages/coverage-android/scripts/harness-coverage-init.gradle new file mode 100644 index 00000000..e1ece281 --- /dev/null +++ b/packages/coverage-android/scripts/harness-coverage-init.gradle @@ -0,0 +1,174 @@ +/** + * React Native Harness — Android coverage init script. + * + * Applies JaCoCo offline instrumentation to specified Gradle modules so that + * coverage data can be collected from a running app (not just androidTest). + * + * Usage: + * ./gradlew assembleDebug \ + * --init-script node_modules/@react-native-harness/coverage-android/scripts/harness-coverage-init.gradle \ + * -PHarnessCoverageModules=:android + * + * The modules to instrument are read from the Gradle property + * `HarnessCoverageModules` (colon-prefixed, comma-separated, e.g. ":mylib,:other") + * or the environment variable `HARNESS_COVERAGE_MODULES`. + * + * For each target module this script: + * 1. Adds the JaCoCo agent runtime dependency (provides RT.getAgent()) + * 2. Saves original (uninstrumented) class files for report generation + * 3. Offline-instruments the compiled class files with JaCoCo probes + * 4. Copies jacococli.jar to the build output for later report generation + * 5. Injects the Harness coverage runtime (CoverageHelper + CoverageInitProvider) + * 6. Adds a COVERAGE_ENABLED BuildConfig field + * + * After the build, the following artifacts are available in each module's + * build/harness-coverage/ directory: + * - original-classes/ — uninstrumented class files needed for the report + * - jacococli.jar — JaCoCo CLI for merging .ec files and generating reports + */ + +def resolveCoverageModules() { + def prop = gradle.startParameter.projectProperties['HarnessCoverageModules'] + if (prop) return prop.split(',').collect { it.trim() } + + def env = System.getenv('HARNESS_COVERAGE_MODULES') + if (env) return env.split(',').collect { it.trim() } + + return [] +} + +def targetModules = resolveCoverageModules() +if (targetModules.isEmpty()) { + logger.warn('[HarnessCoverage] No modules specified — set -PHarnessCoverageModules=:mylib or HARNESS_COVERAGE_MODULES env var') + return +} + +logger.lifecycle("[HarnessCoverage] Will instrument modules: ${targetModules.join(', ')}") + +// Locate the coverage-android package directory (parent of scripts/) +def scriptDir = buildscript.sourceFile?.parentFile +def packageDir = scriptDir?.parentFile + +gradle.projectsEvaluated { + def jacocoVersion = '0.8.12' + + rootProject.allprojects { project -> + if (!targetModules.contains(project.path)) return + + logger.lifecycle("[HarnessCoverage] Configuring ${project.path}") + + // --- Dependencies --- + project.configurations.maybeCreate('jacocoAnt') + project.configurations.maybeCreate('jacocoCli') + + project.dependencies { + implementation "org.jacoco:org.jacoco.agent:${jacocoVersion}:runtime" + jacocoAnt "org.jacoco:org.jacoco.ant:${jacocoVersion}" + jacocoCli "org.jacoco:org.jacoco.cli:${jacocoVersion}:nodeps" + } + + // --- BuildConfig field --- + def androidExt = project.extensions.findByName('android') + if (androidExt) { + androidExt.defaultConfig { + buildConfigField "boolean", "COVERAGE_ENABLED", "true" + } + } + + // --- Inject coverage runtime sources --- + if (androidExt && packageDir) { + def coverageSrcDir = new File(packageDir, 'android/src/main/kotlin') + def coverageResources = new File(packageDir, 'android/src/main/resources') + + androidExt.sourceSets { + debug { + kotlin.srcDirs += coverageSrcDir + resources.srcDirs += coverageResources + } + } + + // Generate a debug-overlay manifest that merges the ContentProvider + // into the app's manifest (avoids replacing the main manifest) + def generatedManifestDir = project.file("${project.buildDir}/generated/harness-coverage") + generatedManifestDir.mkdirs() + def generatedManifest = new File(generatedManifestDir, 'AndroidManifest.xml') + generatedManifest.text = '''\ + + + + + +''' + androidExt.sourceSets.debug.manifest.srcFile generatedManifest + } + + // --- Offline instrumentation + stash build artifacts --- + project.afterEvaluate { + def coverageDir = project.file("${project.buildDir}/harness-coverage") + + // Reusable closure that saves originals, instruments, and stashes jacococli + def instrumentClasses = { File classesDir, String label -> + if (!classesDir.exists()) return + + coverageDir.mkdirs() + + // Save original classes for report generation + def origDir = new File(coverageDir, "original-classes-${label}") + if (origDir.exists()) origDir.deleteDir() + ant.copy(todir: origDir) { + fileset(dir: classesDir) + } + + // Stash jacococli.jar for use at report time + def destJar = new File(coverageDir, 'jacococli.jar') + if (!destJar.exists()) { + def cliJar = project.configurations.jacocoCli.singleFile + ant.copy(file: cliJar, tofile: destJar) + logger.lifecycle("[HarnessCoverage] Stashed ${destJar}") + } + + // Instrument to temp dir, then replace + def instrumentedDir = project.file("${project.buildDir}/tmp/jacoco-instrumented-${label}") + if (instrumentedDir.exists()) instrumentedDir.deleteDir() + instrumentedDir.mkdirs() + + ant.taskdef( + name: 'instrument', + classname: 'org.jacoco.ant.InstrumentTask', + classpath: project.configurations.jacocoAnt.asPath + ) + ant.instrument(destdir: instrumentedDir) { + fileset(dir: classesDir, includes: '**/*.class') + } + + ant.copy(todir: classesDir, overwrite: true) { + fileset(dir: instrumentedDir) + } + + logger.lifecycle("[HarnessCoverage] Instrumented ${label} classes in ${classesDir}") + } + + // Kotlin classes + def kotlinTask = project.tasks.findByName('compileDebugKotlin') + if (kotlinTask) { + def kotlinClasses = project.file("${project.buildDir}/tmp/kotlin-classes/debug") + kotlinTask.doLast { instrumentClasses(kotlinClasses, 'kotlin') } + } + + // Java classes + def javaTask = project.tasks.findByName('compileDebugJavaWithJavac') + if (javaTask) { + def javaClasses = project.file("${project.buildDir}/intermediates/javac/debug/classes") + javaTask.doLast { instrumentClasses(javaClasses, 'java') } + } + + if (!kotlinTask && !javaTask) { + logger.warn("[HarnessCoverage] No compile tasks found in ${project.path}, skipping instrumentation") + } + } + } +} diff --git a/packages/coverage-android/scripts/resolve-coverage-modules.mjs b/packages/coverage-android/scripts/resolve-coverage-modules.mjs new file mode 100644 index 00000000..abc9ddeb --- /dev/null +++ b/packages/coverage-android/scripts/resolve-coverage-modules.mjs @@ -0,0 +1,5 @@ +import { getConfig } from '@react-native-harness/config'; + +const { config } = await getConfig(process.cwd()); +const modules = config.coverage?.native?.android?.modules ?? []; +console.log(JSON.stringify(modules)); diff --git a/packages/coverage-android/src/index.ts b/packages/coverage-android/src/index.ts new file mode 100644 index 00000000..cb0ff5c3 --- /dev/null +++ b/packages/coverage-android/src/index.ts @@ -0,0 +1 @@ +export {}; diff --git a/packages/coverage-android/tsconfig.json b/packages/coverage-android/tsconfig.json new file mode 100644 index 00000000..af1b3657 --- /dev/null +++ b/packages/coverage-android/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "../config" + }, + { + "path": "./tsconfig.lib.json" + } + ] +} diff --git a/packages/coverage-android/tsconfig.lib.json b/packages/coverage-android/tsconfig.lib.json new file mode 100644 index 00000000..385885be --- /dev/null +++ b/packages/coverage-android/tsconfig.lib.json @@ -0,0 +1,19 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "outDir": "dist", + "tsBuildInfoFile": "dist/tsconfig.lib.tsbuildinfo", + "emitDeclarationOnly": false, + "forceConsistentCasingInFileNames": true, + "types": ["node"], + "lib": ["DOM", "ES2022"] + }, + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../config/tsconfig.lib.json" + } + ] +} diff --git a/packages/coverage-ios/tsconfig.json b/packages/coverage-ios/tsconfig.json index c23e61c8..af1b3657 100644 --- a/packages/coverage-ios/tsconfig.json +++ b/packages/coverage-ios/tsconfig.json @@ -3,6 +3,9 @@ "files": [], "include": [], "references": [ + { + "path": "../config" + }, { "path": "./tsconfig.lib.json" } diff --git a/packages/coverage-ios/tsconfig.lib.json b/packages/coverage-ios/tsconfig.lib.json index 7370b55e..385885be 100644 --- a/packages/coverage-ios/tsconfig.lib.json +++ b/packages/coverage-ios/tsconfig.lib.json @@ -10,5 +10,10 @@ "types": ["node"], "lib": ["DOM", "ES2022"] }, - "include": ["src/**/*.ts"] + "include": ["src/**/*.ts"], + "references": [ + { + "path": "../config/tsconfig.lib.json" + } + ] } diff --git a/packages/jest/src/harness-session.ts b/packages/jest/src/harness-session.ts index a19b2387..7443ce40 100644 --- a/packages/jest/src/harness-session.ts +++ b/packages/jest/src/harness-session.ts @@ -558,19 +558,34 @@ export const createHarnessSession = async ( bridge.off('disconnected', onDisconnected); bridge.off('event', bridgeEventListener); - const nativeCoverageConfig = runtimeConfig.coverage?.native?.ios; - if (nativeCoverageConfig?.pods?.length && platformInstance.collectNativeCoverage) { - try { - await platformInstance.stopApp(); - const lcovPath = await platformInstance.collectNativeCoverage({ - pods: nativeCoverageConfig.pods, - outputDir: projectRoot, - }); - if (lcovPath) { - logNativeCoverageCollected(lcovPath); + if (platformInstance.collectNativeCoverage) { + const isAndroid = platform.platformId === 'android'; + const iosPods = runtimeConfig.coverage?.native?.ios?.pods; + const androidModules = runtimeConfig.coverage?.native?.android?.modules; + + const coverageOptions = isAndroid + ? androidModules?.length + ? { modules: androidModules, outputDir: projectRoot } + : null + : iosPods?.length + ? { pods: iosPods, outputDir: projectRoot } + : null; + + if (coverageOptions) { + try { + await platformInstance.stopApp(); + if (isAndroid) { + // JaCoCo flush timer runs every 1s; wait for final write + await new Promise((r) => setTimeout(r, 2000)); + } + const lcovPath = + await platformInstance.collectNativeCoverage(coverageOptions); + if (lcovPath) { + logNativeCoverageCollected(lcovPath); + } + } catch (error) { + sessionLogger.warn('failed to collect native coverage: %s', error); } - } catch (error) { - sessionLogger.warn('failed to collect native coverage: %s', error); } } diff --git a/packages/platform-android/src/coverage-collector.ts b/packages/platform-android/src/coverage-collector.ts new file mode 100644 index 00000000..75b7ebb3 --- /dev/null +++ b/packages/platform-android/src/coverage-collector.ts @@ -0,0 +1,380 @@ +import { spawn, logger } from '@react-native-harness/tools'; +import { getAdbBinaryPath } from './environment.js'; +import fs from 'node:fs'; +import path from 'node:path'; + +const coverageLogger = logger.child('android-coverage'); + +export const pullEcFiles = async ( + adbId: string, + bundleId: string, + localDir: string +): Promise => { + fs.mkdirSync(localDir, { recursive: true }); + + const adb = getAdbBinaryPath(); + const tmpDir = `/data/local/tmp/harness-coverage-${Date.now()}`; + + try { + // List .ec files in app internal storage + const { stdout: listing } = await spawn(adb, [ + '-s', + adbId, + 'shell', + 'run-as', + bundleId, + 'ls', + 'files/', + ]); + + const ecFileNames = listing + .split('\n') + .map((f) => f.trim()) + .filter((f) => f.endsWith('.ec')); + + if (ecFileNames.length === 0) { + return []; + } + + // Copy files to a world-readable temp dir so `adb pull` can access them. + // `run-as` files are in the app's private directory, which `adb pull` + // cannot read directly. + await spawn(adb, ['-s', adbId, 'shell', 'mkdir', '-p', tmpDir]); + + for (const ecFile of ecFileNames) { + try { + await spawn(adb, [ + '-s', + adbId, + 'shell', + 'run-as', + bundleId, + 'cp', + `files/${ecFile}`, + `${tmpDir}/${ecFile}`, + ]); + } catch (e) { + coverageLogger.debug('Failed to copy %s to tmp: %s', ecFile, e); + } + } + + // Pull all .ec files from the temp dir using adb pull (handles binary correctly) + await spawn(adb, ['-s', adbId, 'pull', tmpDir + '/.', localDir]); + } catch (e) { + coverageLogger.debug('Failed to pull .ec files: %s', e); + } finally { + // Clean up the temp dir on device + try { + await spawn(adb, ['-s', adbId, 'shell', 'rm', '-rf', tmpDir]); + } catch { + // best-effort cleanup + } + } + + return fs + .readdirSync(localDir) + .filter((f) => f.endsWith('.ec')) + .map((f) => path.join(localDir, f)); +}; + +/** + * Finds the harness-coverage build artifacts for a module. + * The Gradle init script saves these to /build/harness-coverage/: + * - original-classes-kotlin/ (uninstrumented Kotlin class files) + * - original-classes-java/ (uninstrumented Java class files) + * - jacococli.jar (JaCoCo CLI) + */ +const findCoverageArtifacts = ( + projectRoot: string, + modules: string[] +): { + jacococliJar: string | null; + classesDirs: string[]; + sourceDirs: string[]; +} => { + let jacococliJar: string | null = null; + const classesDirs: string[] = []; + const sourceDirs: string[] = []; + + for (const mod of modules) { + const modDir = mod.replace(/^:/, ''); + const coverageDir = path.join( + projectRoot, + modDir, + 'build', + 'harness-coverage' + ); + + let foundClasses = false; + for (const suffix of ['kotlin', 'java']) { + const classesDir = path.join(coverageDir, `original-classes-${suffix}`); + if (fs.existsSync(classesDir)) { + classesDirs.push(classesDir); + foundClasses = true; + } + } + if (!foundClasses) { + coverageLogger.warn( + '[coverage] Original class files not found in %s — was the app built with the coverage init script?', + coverageDir + ); + } + + if (!jacococliJar) { + const jar = path.join(coverageDir, 'jacococli.jar'); + if (fs.existsSync(jar)) { + jacococliJar = jar; + } + } + + for (const srcPath of [ + path.join(projectRoot, modDir, 'src', 'main', 'java'), + path.join(projectRoot, modDir, 'src', 'main', 'kotlin'), + ]) { + if (fs.existsSync(srcPath)) { + sourceDirs.push(srcPath); + } + } + } + + return { jacococliJar, classesDirs, sourceDirs }; +}; + +const mergeEcFiles = async ( + jacococliJar: string, + ecFiles: string[], + outputPath: string +): Promise => { + await spawn('java', [ + '-jar', + jacococliJar, + 'merge', + ...ecFiles, + '--destfile', + outputPath, + ]); +}; + +const generateXmlReport = async (options: { + jacococliJar: string; + execFile: string; + classesDirs: string[]; + sourceDirs: string[]; + outputPath: string; +}): Promise => { + const { jacococliJar, execFile, classesDirs, sourceDirs, outputPath } = + options; + + const args = ['-jar', jacococliJar, 'report', execFile]; + + for (const dir of classesDirs) { + args.push('--classfiles', dir); + } + for (const dir of sourceDirs) { + args.push('--sourcefiles', dir); + } + args.push('--xml', outputPath); + + await spawn('java', args); +}; + +const getAttr = (tag: string, name: string): string => { + const match = tag.match(new RegExp(`${name}="([^"]*)"`)); + return match?.[1] ?? ''; +}; + +/** + * Resolves a package-relative source path (e.g. "com/example/Foo.kt") to an + * absolute path by checking which source directory contains the file. + */ +const resolveSourcePath = ( + relativePath: string, + sourceDirs: string[] +): string => { + for (const srcDir of sourceDirs) { + const candidate = path.join(srcDir, relativePath); + if (fs.existsSync(candidate)) { + return candidate; + } + } + return relativePath; +}; + +/** + * Converts a JaCoCo XML report to lcov format. + * + * JaCoCo XML structure: + * + * + * + * + * + * + * + */ +export const convertJacocoXmlToLcov = ( + xmlPath: string, + lcovPath: string, + sourceDirs: string[] = [] +): void => { + const xml = fs.readFileSync(xmlPath, 'utf-8'); + const output: string[] = []; + + let currentPackage = ''; + let fileLines: Array<{ nr: number; hits: number }> = []; + let filePath = ''; + + const tagRegex = /<(\/?)(\w+)([^>]*?)(\/?)>/g; + let match; + + while ((match = tagRegex.exec(xml)) !== null) { + const isClosing = match[1] === '/'; + const tagName = match[2]; + const attrs = match[3]; + const isSelfClosing = match[4] === '/'; + + if (!isClosing) { + if (tagName === 'package') { + currentPackage = getAttr(attrs, 'name'); + } else if (tagName === 'sourcefile') { + const fileName = getAttr(attrs, 'name'); + filePath = currentPackage + ? `${currentPackage}/${fileName}` + : fileName; + fileLines = []; + } else if (tagName === 'line' && filePath) { + const nr = parseInt(getAttr(attrs, 'nr') || '0', 10); + const ci = parseInt(getAttr(attrs, 'ci') || '0', 10); + const mi = parseInt(getAttr(attrs, 'mi') || '0', 10); + if (nr > 0 && (ci > 0 || mi > 0)) { + fileLines.push({ nr, hits: ci }); + } + } + } + + if (isClosing || isSelfClosing) { + if (tagName === 'sourcefile' && filePath && fileLines.length > 0) { + const resolvedPath = resolveSourcePath(filePath, sourceDirs); + output.push(`SF:${resolvedPath}`); + for (const line of fileLines) { + output.push(`DA:${line.nr},${line.hits}`); + } + output.push(`LF:${fileLines.length}`); + const hitLines = fileLines.filter((l) => l.hits > 0).length; + output.push(`LH:${hitLines}`); + output.push('end_of_record'); + filePath = ''; + fileLines = []; + } else if (tagName === 'package') { + currentPackage = ''; + } + } + } + + fs.writeFileSync(lcovPath, output.join('\n') + '\n'); +}; + +export const cleanEcFilesOnDevice = async ( + adbId: string, + bundleId: string +): Promise => { + const adb = getAdbBinaryPath(); + try { + await spawn(adb, [ + '-s', + adbId, + 'shell', + 'run-as', + bundleId, + 'sh', + '-c', + 'rm -f files/coverage-*.ec', + ]); + } catch { + coverageLogger.debug('Failed to clean .ec files from device'); + } +}; + +export type CollectAndroidNativeCoverageOptions = { + adbId: string; + bundleId: string; + modules: string[]; + outputDir: string; +}; + +export const collectAndroidNativeCoverage = async ( + options: CollectAndroidNativeCoverageOptions +): Promise => { + const { adbId, bundleId, modules, outputDir } = options; + + coverageLogger.debug('[coverage] Collecting native Android coverage', { + adbId, + bundleId, + modules, + }); + + // Pull .ec files from device + const ecDir = path.join(outputDir, '.harness-coverage-ec'); + const ecFiles = await pullEcFiles(adbId, bundleId, ecDir); + + if (ecFiles.length === 0) { + coverageLogger.debug('[coverage] No .ec files found on device'); + return null; + } + + coverageLogger.debug(`[coverage] Found ${ecFiles.length} .ec file(s)`); + + // Find build artifacts (jacococli.jar + original classes) from the Android + // project. For RN apps the Android project is typically at outputDir/android/. + let androidProjectDir = outputDir; + const androidSubdir = path.join(outputDir, 'android'); + if (fs.existsSync(androidSubdir)) { + androidProjectDir = androidSubdir; + } + + const { jacococliJar, classesDirs, sourceDirs } = findCoverageArtifacts( + androidProjectDir, + modules + ); + + if (!jacococliJar) { + coverageLogger.warn( + '[coverage] jacococli.jar not found in build output — was the app built with the coverage init script?' + ); + fs.rmSync(ecDir, { recursive: true, force: true }); + return null; + } + + if (classesDirs.length === 0) { + coverageLogger.warn( + '[coverage] No original class files found — cannot generate report' + ); + fs.rmSync(ecDir, { recursive: true, force: true }); + return null; + } + + // Merge .ec files + const mergedEc = path.join(outputDir, 'native-coverage.exec'); + await mergeEcFiles(jacococliJar, ecFiles, mergedEc); + + // Generate XML report + const xmlPath = path.join(outputDir, 'native-coverage.xml'); + await generateXmlReport({ + jacococliJar, + execFile: mergedEc, + classesDirs, + sourceDirs, + outputPath: xmlPath, + }); + + // Convert to lcov + const lcovPath = path.join(outputDir, 'native-coverage.lcov'); + convertJacocoXmlToLcov(xmlPath, lcovPath, sourceDirs); + + // Clean up + fs.rmSync(ecDir, { recursive: true, force: true }); + await cleanEcFilesOnDevice(adbId, bundleId); + + coverageLogger.debug(`[coverage] Native coverage written to: ${lcovPath}`); + return lcovPath; +}; diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index d28eb177..cd10eeea 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -1,5 +1,6 @@ import { AppNotInstalledError, + type CollectNativeCoverageOptions, CreateAppMonitorOptions, DeviceNotFoundError, type HarnessPlatformInitOptions, @@ -329,6 +330,17 @@ export const getAndroidEmulatorPlatformInstance = async ( crashArtifactWriter: options?.crashArtifactWriter, }); }, + collectNativeCoverage: async (options: CollectNativeCoverageOptions) => { + const { collectAndroidNativeCoverage } = await import( + './coverage-collector.js' + ); + return collectAndroidNativeCoverage({ + adbId, + bundleId: config.bundleId, + modules: options.modules ?? [], + outputDir: options.outputDir, + }); + }, }; }; @@ -404,5 +416,16 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( crashArtifactWriter: options?.crashArtifactWriter, }); }, + collectNativeCoverage: async (options: CollectNativeCoverageOptions) => { + const { collectAndroidNativeCoverage } = await import( + './coverage-collector.js' + ); + return collectAndroidNativeCoverage({ + adbId, + bundleId: config.bundleId, + modules: options.modules ?? [], + outputDir: options.outputDir, + }); + }, }; }; diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 36c62cd1..543cbb1c 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -203,7 +203,7 @@ export const getAppleSimulatorPlatformInstance = async ( return await collectNativeCoverage({ udid, bundleId: config.bundleId, - pods: options.pods, + pods: options.pods ?? [], outputDir: options.outputDir, }); }, diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index f4d16819..ac40c2eb 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -99,8 +99,9 @@ export type AppLaunchOptions = | VegaAppLaunchOptions; export type CollectNativeCoverageOptions = { - pods: string[]; outputDir: string; + pods?: string[]; + modules?: string[]; }; export type HarnessPlatformRunner = { diff --git a/tsconfig.json b/tsconfig.json index 4d6eb235..f8ebf30f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -62,6 +62,9 @@ }, { "path": "./packages/coverage-ios" + }, + { + "path": "./packages/coverage-android" } ] } From 126caadffb60a9c01c89a4277f7d8fa398b9afb2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 19 May 2026 16:10:12 +0200 Subject: [PATCH 2/3] fix: init script timing and manifest interpolation bugs - Use allprojects+afterEvaluate instead of projectsEvaluated (too late for source sets) - Use configureEach for lazily-registered compile tasks - Fix manifest template to use double-quoted string for ${applicationId} - Remove redundant BuildConfig guard from CoverageHelper - Set jacocoCli transitive=false - Remove ANDROID_COVERAGE_GUIDE.md from repo --- ANDROID_COVERAGE_GUIDE.md | 161 ------------------ .../com/harness/coverage/CoverageHelper.kt | 2 - .../scripts/harness-coverage-init.gradle | 130 +++++++------- 3 files changed, 65 insertions(+), 228 deletions(-) delete mode 100644 ANDROID_COVERAGE_GUIDE.md diff --git a/ANDROID_COVERAGE_GUIDE.md b/ANDROID_COVERAGE_GUIDE.md deleted file mode 100644 index 999c5c90..00000000 --- a/ANDROID_COVERAGE_GUIDE.md +++ /dev/null @@ -1,161 +0,0 @@ -# Android Native Coverage — Local Testing Guide - -How to test Android native (Kotlin/Java) code coverage collection with a local React Native library module. - -## Prerequisites - -- Android SDK with an emulator image -- Java 11+ (for JaCoCo CLI) -- A React Native library with an `android/` module and an example/playground app - -## 1. Install the coverage package - -From your library's example/playground app directory: - -```bash -# If using the harness monorepo locally (linked): -pnpm add @react-native-harness/coverage-android --workspace - -# Or from npm (once published): -npm install --save-dev @react-native-harness/coverage-android -``` - -## 2. Build the app with coverage instrumentation - -The init script handles everything — no changes to your `build.gradle` needed. - -```bash -cd android - -./gradlew assembleDebug \ - --init-script ../node_modules/@react-native-harness/coverage-android/scripts/harness-coverage-init.gradle \ - -PHarnessCoverageModules=:mylib - -cd .. -``` - -Replace `:mylib` with your library's Gradle module path (e.g. `:react-native-my-lib`, `:android`). You can instrument multiple modules: `-PHarnessCoverageModules=:moduleA,:moduleB`. - -### What the init script does - -- Adds JaCoCo offline instrumentation to the specified modules' compiled classes -- Injects `CoverageHelper` + `CoverageInitProvider` into the debug build -- Saves original (uninstrumented) class files + `jacococli.jar` to `/build/harness-coverage/` -- Adds `BuildConfig.COVERAGE_ENABLED = true` - -### Verify instrumentation worked - -```bash -javap -p android//build/tmp/kotlin-classes/debug/com/example/MyClass.class | grep jacoco -``` - -You should see `$jacocoInit` — that means JaCoCo probes are present. - -## 3. Configure harness - -In your `rn-harness.config.mjs`: - -```javascript -import { androidPlatform, androidEmulator } from '@react-native-harness/platform-android'; - -export default { - entryPoint: './index.js', - appRegistryComponentName: 'MyApp', - runners: [ - androidPlatform({ - name: 'android', - device: androidEmulator('Pixel_8_API_35'), - bundleId: 'com.example.myapp', - }), - ], - coverage: { - native: { - android: { - modules: [':mylib'], - }, - }, - }, -}; -``` - -The `modules` array must match the module paths you passed to the init script. - -## 4. Run tests with coverage - -```bash -npx react-native-harness --coverage --harnessRunner android -``` - -After tests complete, the harness will: - -1. Stop the app (triggers `am force-stop`) -2. Wait 2 seconds for the JaCoCo flush timer to write final data -3. Pull `.ec` files from the app's internal storage via `adb` -4. Merge them using `jacococli.jar` (from the build output) -5. Generate an XML report using the original (uninstrumented) class files -6. Convert to lcov format - -Output: `native-coverage.lcov` in the project root. - -## 5. View the report - -```bash -# Quick summary -grep -c "^DA:" native-coverage.lcov -# -> number of instrumented lines - -# Generate HTML (requires lcov tools) -genhtml native-coverage.lcov -o coverage-html -open coverage-html/index.html -``` - -## How it works - -### Build time - -The Gradle init script hooks into `compileDebugKotlin` (and `compileDebugJavaWithJavac` if present). After compilation: - -1. Copies original `.class` files to `build/harness-coverage/original-classes-kotlin/` (needed for reports since instrumented classes have different bytecode) -2. Runs JaCoCo's `InstrumentTask` to rewrite `.class` files with coverage probes -3. Copies `jacococli.jar` to `build/harness-coverage/` so it's available at report time without needing Gradle - -### Runtime - -`CoverageInitProvider` (a `ContentProvider`) bootstraps `CoverageHelper.setup()` before any Activity starts. The helper: - -- Writes coverage data to `context.filesDir/coverage-{pid}.ec` every 1 second via a daemon timer -- Also flushes on `onActivityStopped` - -Each app restart (the harness restarts per test suite) gets its own `.ec` file keyed by PID. - -### Collection - -The coverage collector pulls `.ec` files from the device by copying them to `/data/local/tmp/` via `adb shell run-as`, then using `adb pull` (which handles binary data correctly). It then uses `jacococli.jar` from the build output to merge and generate reports. - -## Troubleshooting - -### "Original class files not found" - -The build output wasn't found at test time. Make sure: -- You built with `--init-script` and the correct `-PHarnessCoverageModules` -- The `modules` in `rn-harness.config.mjs` match the Gradle module paths -- If build and test run on different machines, transfer the entire `/build/harness-coverage/` directory - -### "jacococli.jar not found" - -Same as above — the init script stashes `jacococli.jar` during the build. If it's missing, the build didn't use the init script. - -### "No .ec files found on device" - -The app didn't write coverage data. Check: -- Was the app built with the init script? (`javap -p ... | grep jacoco`) -- Did the app actually run? (check adb logcat for `HarnessCoverage` tag) -- Is `BuildConfig.COVERAGE_ENABLED` true? - -### 0% coverage on everything - -The `.ec` data doesn't match the class files. This happens when you rebuild without re-running tests, or vice versa. Always use matching build + test runs. - -### `EROFS` crash on startup - -Missing `jacoco-agent.properties` with `output=none`. The init script injects this automatically — if you see this error, the init script wasn't applied correctly. diff --git a/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageHelper.kt b/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageHelper.kt index 23d52423..11e4d877 100644 --- a/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageHelper.kt +++ b/packages/coverage-android/android/src/main/kotlin/com/harness/coverage/CoverageHelper.kt @@ -14,8 +14,6 @@ object CoverageHelper { private var cachedAgent: Any? = null fun setup(context: Context) { - if (!BuildConfig.COVERAGE_ENABLED) return - val agent = try { Class.forName("org.jacoco.agent.rt.RT") .getMethod("getAgent") diff --git a/packages/coverage-android/scripts/harness-coverage-init.gradle b/packages/coverage-android/scripts/harness-coverage-init.gradle index e1ece281..f940853b 100644 --- a/packages/coverage-android/scripts/harness-coverage-init.gradle +++ b/packages/coverage-android/scripts/harness-coverage-init.gradle @@ -49,17 +49,27 @@ logger.lifecycle("[HarnessCoverage] Will instrument modules: ${targetModules.joi def scriptDir = buildscript.sourceFile?.parentFile def packageDir = scriptDir?.parentFile -gradle.projectsEvaluated { - def jacocoVersion = '0.8.12' +def jacocoVersion = '0.8.12' - rootProject.allprojects { project -> - if (!targetModules.contains(project.path)) return +// Use allprojects + afterEvaluate so that source sets, dependencies, and +// BuildConfig fields are registered BEFORE the compile tasks finalize their +// inputs. gradle.projectsEvaluated is too late for source-set changes. +gradle.allprojects { project -> + if (!targetModules.contains(project.path)) return + project.afterEvaluate { logger.lifecycle("[HarnessCoverage] Configuring ${project.path}") + def androidExt = project.extensions.findByName('android') + if (!androidExt) { + logger.warn("[HarnessCoverage] ${project.path} has no android extension, skipping") + return + } + // --- Dependencies --- project.configurations.maybeCreate('jacocoAnt') - project.configurations.maybeCreate('jacocoCli') + def jacocoCliConf = project.configurations.maybeCreate('jacocoCli') + jacocoCliConf.transitive = false project.dependencies { implementation "org.jacoco:org.jacoco.agent:${jacocoVersion}:runtime" @@ -68,15 +78,13 @@ gradle.projectsEvaluated { } // --- BuildConfig field --- - def androidExt = project.extensions.findByName('android') - if (androidExt) { - androidExt.defaultConfig { - buildConfigField "boolean", "COVERAGE_ENABLED", "true" - } + def namespace = androidExt.namespace + androidExt.defaultConfig { + buildConfigField "boolean", "COVERAGE_ENABLED", "true" } // --- Inject coverage runtime sources --- - if (androidExt && packageDir) { + if (packageDir) { def coverageSrcDir = new File(packageDir, 'android/src/main/kotlin') def coverageResources = new File(packageDir, 'android/src/main/resources') @@ -88,86 +96,78 @@ gradle.projectsEvaluated { } // Generate a debug-overlay manifest that merges the ContentProvider - // into the app's manifest (avoids replacing the main manifest) def generatedManifestDir = project.file("${project.buildDir}/generated/harness-coverage") generatedManifestDir.mkdirs() def generatedManifest = new File(generatedManifestDir, 'AndroidManifest.xml') - generatedManifest.text = '''\ + generatedManifest.text = """\ -''' +""" androidExt.sourceSets.debug.manifest.srcFile generatedManifest } // --- Offline instrumentation + stash build artifacts --- - project.afterEvaluate { - def coverageDir = project.file("${project.buildDir}/harness-coverage") + def coverageDir = project.file("${project.buildDir}/harness-coverage") - // Reusable closure that saves originals, instruments, and stashes jacococli - def instrumentClasses = { File classesDir, String label -> - if (!classesDir.exists()) return + def instrumentClasses = { File classesDir, String label -> + if (!classesDir.exists()) return - coverageDir.mkdirs() + coverageDir.mkdirs() - // Save original classes for report generation - def origDir = new File(coverageDir, "original-classes-${label}") - if (origDir.exists()) origDir.deleteDir() - ant.copy(todir: origDir) { - fileset(dir: classesDir) - } - - // Stash jacococli.jar for use at report time - def destJar = new File(coverageDir, 'jacococli.jar') - if (!destJar.exists()) { - def cliJar = project.configurations.jacocoCli.singleFile - ant.copy(file: cliJar, tofile: destJar) - logger.lifecycle("[HarnessCoverage] Stashed ${destJar}") - } + // Save original classes for report generation + def origDir = new File(coverageDir, "original-classes-${label}") + if (origDir.exists()) origDir.deleteDir() + ant.copy(todir: origDir) { + fileset(dir: classesDir) + } - // Instrument to temp dir, then replace - def instrumentedDir = project.file("${project.buildDir}/tmp/jacoco-instrumented-${label}") - if (instrumentedDir.exists()) instrumentedDir.deleteDir() - instrumentedDir.mkdirs() - - ant.taskdef( - name: 'instrument', - classname: 'org.jacoco.ant.InstrumentTask', - classpath: project.configurations.jacocoAnt.asPath - ) - ant.instrument(destdir: instrumentedDir) { - fileset(dir: classesDir, includes: '**/*.class') - } + // Stash jacococli.jar for use at report time + def destJar = new File(coverageDir, 'jacococli.jar') + if (!destJar.exists()) { + def cliJar = project.configurations.jacocoCli.singleFile + ant.copy(file: cliJar, tofile: destJar) + logger.lifecycle("[HarnessCoverage] Stashed ${destJar}") + } - ant.copy(todir: classesDir, overwrite: true) { - fileset(dir: instrumentedDir) - } + // Instrument to temp dir, then replace originals + def instrumentedDir = project.file("${project.buildDir}/tmp/jacoco-instrumented-${label}") + if (instrumentedDir.exists()) instrumentedDir.deleteDir() + instrumentedDir.mkdirs() + + ant.taskdef( + name: 'instrument', + classname: 'org.jacoco.ant.InstrumentTask', + classpath: project.configurations.jacocoAnt.asPath + ) + ant.instrument(destdir: instrumentedDir) { + fileset(dir: classesDir, includes: '**/*.class') + } - logger.lifecycle("[HarnessCoverage] Instrumented ${label} classes in ${classesDir}") + ant.copy(todir: classesDir, overwrite: true) { + fileset(dir: instrumentedDir) } - // Kotlin classes - def kotlinTask = project.tasks.findByName('compileDebugKotlin') - if (kotlinTask) { + logger.lifecycle("[HarnessCoverage] Instrumented ${label} classes in ${classesDir}") + } + + // Hook into compile tasks. Use configureEach with name matching so it + // works with lazily-registered tasks (AGP + RN Gradle plugin register + // compile tasks after project evaluation, during task graph resolution). + project.tasks.configureEach { task -> + if (task.name == 'compileDebugKotlin') { def kotlinClasses = project.file("${project.buildDir}/tmp/kotlin-classes/debug") - kotlinTask.doLast { instrumentClasses(kotlinClasses, 'kotlin') } + task.doLast { instrumentClasses(kotlinClasses, 'kotlin') } } - - // Java classes - def javaTask = project.tasks.findByName('compileDebugJavaWithJavac') - if (javaTask) { + if (task.name == 'compileDebugJavaWithJavac') { def javaClasses = project.file("${project.buildDir}/intermediates/javac/debug/classes") - javaTask.doLast { instrumentClasses(javaClasses, 'java') } - } - - if (!kotlinTask && !javaTask) { - logger.warn("[HarnessCoverage] No compile tasks found in ${project.path}, skipping instrumentation") + task.doLast { instrumentClasses(javaClasses, 'java') } } } } From 7a14eec752f2bd69e822d844687a64356362b821 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mikl=C3=B3s=20Fazekas?= Date: Tue, 19 May 2026 16:16:31 +0200 Subject: [PATCH 3/3] docs: add README, version plan, and website docs for Android coverage --- ...dd-experimental-android-native-coverage.md | 5 + packages/coverage-android/README.md | 107 ++++++++++++++++++ pnpm-lock.yaml | 10 ++ .../docs/getting-started/configuration.mdx | 1 + website/src/docs/guides/native-coverage.mdx | 103 ++++++++++++++--- 5 files changed, 208 insertions(+), 18 deletions(-) create mode 100644 .nx/version-plans/add-experimental-android-native-coverage.md create mode 100644 packages/coverage-android/README.md diff --git a/.nx/version-plans/add-experimental-android-native-coverage.md b/.nx/version-plans/add-experimental-android-native-coverage.md new file mode 100644 index 00000000..c68923d3 --- /dev/null +++ b/.nx/version-plans/add-experimental-android-native-coverage.md @@ -0,0 +1,5 @@ +--- +__default__: minor +--- + +Harness now offers experimental native Android coverage for selected Gradle modules, so you can see which native Kotlin/Java code paths your Harness tests exercise. After a covered run, Harness produces `native-coverage.lcov`, giving you a concrete way to inspect and report native coverage alongside your existing test results. diff --git a/packages/coverage-android/README.md b/packages/coverage-android/README.md new file mode 100644 index 00000000..50ded6bf --- /dev/null +++ b/packages/coverage-android/README.md @@ -0,0 +1,107 @@ +![harness-banner](https://react-native-harness.dev/harness-banner.jpg) + +### Experimental Android Native Coverage for React Native Harness + +[![mit licence][license-badge]][license] +[![npm downloads][npm-downloads-badge]][npm-downloads] +[![Chat][chat-badge]][chat] +[![PRs Welcome][prs-welcome-badge]][prs-welcome] + +⚠️ **EXPERIMENTAL** ⚠️ + +`@react-native-harness/coverage-android` adds native Android code coverage collection for React Native Harness. It uses JaCoCo offline instrumentation to instrument selected Gradle modules, collects `.ec` execution data files from the app during test runs, and writes a `native-coverage.lcov` report after the run finishes. + +Coverage collection is supported on **Android emulators and physical devices** (debug builds only). + +## Installation + +```bash +npm install --save-dev @react-native-harness/coverage-android +# or +pnpm add -D @react-native-harness/coverage-android +# or +yarn add -D @react-native-harness/coverage-android +``` + +After installation, rebuild the app with the coverage init script (see Usage). + +## Usage + +Build the app with JaCoCo offline instrumentation: + +```bash +cd android +./gradlew assembleDebug \ + --init-script ../node_modules/@react-native-harness/coverage-android/scripts/harness-coverage-init.gradle \ + -PHarnessCoverageModules=:mylib +cd .. +``` + +Add the modules you want to instrument in `rn-harness.config.mjs`: + +```javascript +import { androidPlatform, androidEmulator } from '@react-native-harness/platform-android'; + +export default { + runners: [ + androidPlatform({ + name: 'android', + device: androidEmulator('Pixel_8_API_35'), + bundleId: 'com.example.app', + }), + ], + coverage: { + native: { + android: { + modules: [':mylib'], + }, + }, + }, +}; +``` + +Run Harness with coverage enabled: + +```bash +react-native-harness --coverage --harnessRunner android +``` + +When coverage is collected successfully, Harness writes `native-coverage.lcov` to the project root. + +## How it works + +- A Gradle init script applies JaCoCo offline instrumentation to compiled Kotlin/Java class files +- Injects a ContentProvider that bootstraps a coverage flush helper on app startup +- The helper writes JaCoCo execution data (`.ec` files) to app internal storage every second +- After tests, Harness pulls `.ec` files from the device, merges them, and generates LCOV + +## Requirements + +- Android SDK with emulator or physical device +- Java 11+ (for JaCoCo CLI) +- Android runner configured with `@react-native-harness/platform-android` +- Debug build of the app using the coverage init script +- `@react-native-harness/coverage-android` installed (provides the init script and runtime helpers) + +## Limitations + +- Experimental and subject to change +- Requires building with the Gradle init script (`--init-script`) +- Coverage collection writes reports to the project root +- Build and test environments must share access to the build output (original class files + `jacococli.jar`) + +## Made with ❤️ at Callstack + +`@react-native-harness/coverage-android` is an open source project and will always remain free to use. If you think it's cool, please star it 🌟. [Callstack][callstack-readme-with-love] is a group of React and React Native geeks, contact us at [hello@callstack.com](mailto:hello@callstack.com) if you need any help with these or just want to say hi! + +Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_campaign=Senior_RN&utm_source=github&utm_medium=readme) who does amazing stuff for clients and drives React Native Open Source! 🔥 + +[callstack-readme-with-love]: https://callstack.com/?utm_source=github.com&utm_medium=referral&utm_campaign=react-native-harness&utm_term=readme-with-love +[license-badge]: https://img.shields.io/npm/l/@react-native-harness/coverage-android?style=for-the-badge +[license]: https://github.com/callstackincubator/react-native-harness/blob/main/LICENSE +[npm-downloads-badge]: https://img.shields.io/npm/dm/@react-native-harness/coverage-android?style=for-the-badge +[npm-downloads]: https://www.npmjs.com/package/@react-native-harness/coverage-android +[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge +[prs-welcome]: ../../CONTRIBUTING.md +[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge +[chat]: https://discord.gg/xgGt7KAjxv diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 50b36526..4d321a31 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -344,6 +344,16 @@ importers: specifier: ^3.25.67 version: 3.25.67 + packages/coverage-android: + dependencies: + tslib: + specifier: ^2.3.0 + version: 2.8.1 + devDependencies: + react-native: + specifier: '*' + version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + packages/coverage-ios: dependencies: tslib: diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index f2003098..cc1e67a8 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -108,6 +108,7 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `coverage` | Coverage configuration object. | | `coverage.root` | Root directory for coverage instrumentation (default: `process.cwd()`). | | `coverage.native.ios.pods` | Experimental list of CocoaPods target names to instrument for iOS native coverage. | +| `coverage.native.android.modules` | Experimental list of Gradle module paths to instrument for Android native coverage (e.g. `[":mylib"]`). | | `forwardClientLogs` | Forward console logs from the app to the terminal (default: `false`). | | `unstable__enableMetroCache` | Enable Metro transformation cache under `.harness/metro-cache` and log when reusing it (default: `false`). | diff --git a/website/src/docs/guides/native-coverage.mdx b/website/src/docs/guides/native-coverage.mdx index 5ed6e559..d3ac114d 100644 --- a/website/src/docs/guides/native-coverage.mdx +++ b/website/src/docs/guides/native-coverage.mdx @@ -5,21 +5,21 @@ import { PackageManagerTabs } from '@theme'; React Native Harness provides experimental support for collecting native coverage during test runs. :::warning Experimental -`@react-native-harness/coverage-ios` is an **experimental** feature. Expect rough edges and API changes while the integration matures. +Native coverage packages are **experimental**. Expect rough edges and API changes while the integration matures. ::: -Today, native coverage support is available for **iOS simulators only**. Physical iOS devices are not supported yet. Android support is planned and this guide will expand as it lands. +Native coverage support is available for **iOS simulators** and **Android emulators/devices**. ## What you get - Coverage instrumentation for supported native dependencies -- Automatic `.profraw` collection from the app sandbox +- Automatic coverage data collection from the app - `native-coverage.lcov` output after the test run finishes - A way to measure native code exercised by Harness tests, alongside JavaScript coverage -## Installation +## iOS -Current package: +### Installation @@ -28,9 +28,7 @@ After installation: 1. Run your iOS pod install step. 2. Rebuild the app. -## Configuration - -Current iOS configuration: +### Configuration Add the pods you want to instrument in `rn-harness.config.mjs`: @@ -57,11 +55,7 @@ export default { The `pods` array should contain CocoaPods target names that you want Harness to instrument for coverage. -## Running tests - -Current iOS command: - -Run Harness with coverage enabled: +### Running tests ```bash react-native-harness --coverage --harnessRunner ios @@ -69,14 +63,14 @@ react-native-harness --coverage --harnessRunner ios Harness will stop the app before cleanup, collect generated `.profraw` files, merge them with `llvm-profdata`, and export LCOV with `llvm-cov`. -## Output +### Output When native coverage is collected successfully, Harness writes these files to the project root: - `native-coverage.profdata` - `native-coverage.lcov` -## Requirements +### Requirements - macOS with Xcode installed - An iOS runner configured with `@react-native-harness/platform-apple` @@ -84,9 +78,82 @@ When native coverage is collected successfully, Harness writes these files to th - iOS Simulator - Debug app build -## Limitations +### Limitations -- iOS simulator support is available today; physical iOS devices and Android are not supported yet +- iOS simulator only; physical iOS devices are not supported yet - Current implementation targets pod-based native code -- The feature is experimental and may change without much notice - If LCOV source filtering fails for pod paths, Harness falls back to exporting broader coverage data + +## Android + +### Installation + + + +After installation, rebuild the app with the coverage init script (see below). + +### Build with coverage + +The app must be built with JaCoCo offline instrumentation using the provided Gradle init script: + +```bash +cd android +./gradlew assembleDebug \ + --init-script ../node_modules/@react-native-harness/coverage-android/scripts/harness-coverage-init.gradle \ + -PHarnessCoverageModules=:mylib +cd .. +``` + +Replace `:mylib` with your library's Gradle module path. Multiple modules can be specified with commas: `-PHarnessCoverageModules=:moduleA,:moduleB`. + +### Configuration + +Add the modules you want to instrument in `rn-harness.config.mjs`: + +```javascript +import { androidPlatform, androidEmulator } from '@react-native-harness/platform-android'; + +export default { + runners: [ + androidPlatform({ + name: 'android', + device: androidEmulator('Pixel_8_API_35'), + bundleId: 'com.example.app', + }), + ], + coverage: { + native: { + android: { + modules: [':mylib'], + }, + }, + }, +}; +``` + +The `modules` array must match the module paths passed to the init script during the build. + +### Running tests + +```bash +react-native-harness --coverage --harnessRunner android +``` + +Harness will stop the app, pull `.ec` execution data files from the device, merge them with JaCoCo CLI, and convert the report to LCOV. + +### Output + +When native coverage is collected successfully, Harness writes `native-coverage.lcov` to the project root. + +### Requirements + +- Android SDK with an emulator or physical device +- Java 11+ +- An Android runner configured with `@react-native-harness/platform-android` +- Debug app build using the coverage init script +- `@react-native-harness/coverage-android` installed + +### Limitations + +- Requires building with the Gradle init script (`--init-script`) +- Build and test environments must share access to the build output (original class files + `jacococli.jar` in `/build/harness-coverage/`)