Skip to content

Commit 4b99f17

Browse files
committed
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.
1 parent caab9c3 commit 4b99f17

20 files changed

Lines changed: 961 additions & 15 deletions

File tree

ANDROID_COVERAGE_GUIDE.md

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
# Android Native Coverage — Local Testing Guide
2+
3+
How to test Android native (Kotlin/Java) code coverage collection with a local React Native library module.
4+
5+
## Prerequisites
6+
7+
- Android SDK with an emulator image
8+
- Java 11+ (for JaCoCo CLI)
9+
- A React Native library with an `android/` module and an example/playground app
10+
11+
## 1. Install the coverage package
12+
13+
From your library's example/playground app directory:
14+
15+
```bash
16+
# If using the harness monorepo locally (linked):
17+
pnpm add @react-native-harness/coverage-android --workspace
18+
19+
# Or from npm (once published):
20+
npm install --save-dev @react-native-harness/coverage-android
21+
```
22+
23+
## 2. Build the app with coverage instrumentation
24+
25+
The init script handles everything — no changes to your `build.gradle` needed.
26+
27+
```bash
28+
cd android
29+
30+
./gradlew assembleDebug \
31+
--init-script ../node_modules/@react-native-harness/coverage-android/scripts/harness-coverage-init.gradle \
32+
-PHarnessCoverageModules=:mylib
33+
34+
cd ..
35+
```
36+
37+
Replace `:mylib` with your library's Gradle module path (e.g. `:react-native-my-lib`, `:android`). You can instrument multiple modules: `-PHarnessCoverageModules=:moduleA,:moduleB`.
38+
39+
### What the init script does
40+
41+
- Adds JaCoCo offline instrumentation to the specified modules' compiled classes
42+
- Injects `CoverageHelper` + `CoverageInitProvider` into the debug build
43+
- Saves original (uninstrumented) class files + `jacococli.jar` to `<module>/build/harness-coverage/`
44+
- Adds `BuildConfig.COVERAGE_ENABLED = true`
45+
46+
### Verify instrumentation worked
47+
48+
```bash
49+
javap -p android/<module>/build/tmp/kotlin-classes/debug/com/example/MyClass.class | grep jacoco
50+
```
51+
52+
You should see `$jacocoInit` — that means JaCoCo probes are present.
53+
54+
## 3. Configure harness
55+
56+
In your `rn-harness.config.mjs`:
57+
58+
```javascript
59+
import { androidPlatform, androidEmulator } from '@react-native-harness/platform-android';
60+
61+
export default {
62+
entryPoint: './index.js',
63+
appRegistryComponentName: 'MyApp',
64+
runners: [
65+
androidPlatform({
66+
name: 'android',
67+
device: androidEmulator('Pixel_8_API_35'),
68+
bundleId: 'com.example.myapp',
69+
}),
70+
],
71+
coverage: {
72+
native: {
73+
android: {
74+
modules: [':mylib'],
75+
},
76+
},
77+
},
78+
};
79+
```
80+
81+
The `modules` array must match the module paths you passed to the init script.
82+
83+
## 4. Run tests with coverage
84+
85+
```bash
86+
npx react-native-harness --coverage --harnessRunner android
87+
```
88+
89+
After tests complete, the harness will:
90+
91+
1. Stop the app (triggers `am force-stop`)
92+
2. Wait 2 seconds for the JaCoCo flush timer to write final data
93+
3. Pull `.ec` files from the app's internal storage via `adb`
94+
4. Merge them using `jacococli.jar` (from the build output)
95+
5. Generate an XML report using the original (uninstrumented) class files
96+
6. Convert to lcov format
97+
98+
Output: `native-coverage.lcov` in the project root.
99+
100+
## 5. View the report
101+
102+
```bash
103+
# Quick summary
104+
grep -c "^DA:" native-coverage.lcov
105+
# -> number of instrumented lines
106+
107+
# Generate HTML (requires lcov tools)
108+
genhtml native-coverage.lcov -o coverage-html
109+
open coverage-html/index.html
110+
```
111+
112+
## How it works
113+
114+
### Build time
115+
116+
The Gradle init script hooks into `compileDebugKotlin` (and `compileDebugJavaWithJavac` if present). After compilation:
117+
118+
1. Copies original `.class` files to `build/harness-coverage/original-classes-kotlin/` (needed for reports since instrumented classes have different bytecode)
119+
2. Runs JaCoCo's `InstrumentTask` to rewrite `.class` files with coverage probes
120+
3. Copies `jacococli.jar` to `build/harness-coverage/` so it's available at report time without needing Gradle
121+
122+
### Runtime
123+
124+
`CoverageInitProvider` (a `ContentProvider`) bootstraps `CoverageHelper.setup()` before any Activity starts. The helper:
125+
126+
- Writes coverage data to `context.filesDir/coverage-{pid}.ec` every 1 second via a daemon timer
127+
- Also flushes on `onActivityStopped`
128+
129+
Each app restart (the harness restarts per test suite) gets its own `.ec` file keyed by PID.
130+
131+
### Collection
132+
133+
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.
134+
135+
## Troubleshooting
136+
137+
### "Original class files not found"
138+
139+
The build output wasn't found at test time. Make sure:
140+
- You built with `--init-script` and the correct `-PHarnessCoverageModules`
141+
- The `modules` in `rn-harness.config.mjs` match the Gradle module paths
142+
- If build and test run on different machines, transfer the entire `<module>/build/harness-coverage/` directory
143+
144+
### "jacococli.jar not found"
145+
146+
Same as above — the init script stashes `jacococli.jar` during the build. If it's missing, the build didn't use the init script.
147+
148+
### "No .ec files found on device"
149+
150+
The app didn't write coverage data. Check:
151+
- Was the app built with the init script? (`javap -p ... | grep jacoco`)
152+
- Did the app actually run? (check adb logcat for `HarnessCoverage` tag)
153+
- Is `BuildConfig.COVERAGE_ENABLED` true?
154+
155+
### 0% coverage on everything
156+
157+
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.
158+
159+
### `EROFS` crash on startup
160+
161+
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.

packages/config/src/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,18 @@ export const ConfigSchema = z
118118
),
119119
})
120120
.optional(),
121+
android: z
122+
.object({
123+
modules: z
124+
.array(z.string())
125+
.min(1, 'At least one Gradle module path is required')
126+
.describe(
127+
'Gradle module paths to instrument for native code coverage, ' +
128+
'e.g. [":android"]. The app must be built with the harness coverage ' +
129+
'init script to enable JaCoCo offline instrumentation.'
130+
),
131+
})
132+
.optional(),
121133
})
122134
.optional()
123135
.describe('Native code coverage configuration.'),
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
2+
<application>
3+
<provider
4+
android:name="com.harness.coverage.CoverageInitProvider"
5+
android:authorities="${applicationId}.harness_coverage"
6+
android:exported="false"
7+
android:initOrder="999" />
8+
</application>
9+
</manifest>
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package com.harness.coverage
2+
3+
import android.app.Activity
4+
import android.app.Application
5+
import android.content.Context
6+
import android.os.Bundle
7+
import android.util.Log
8+
import java.io.File
9+
10+
object CoverageHelper {
11+
private const val TAG = "HarnessCoverage"
12+
private var ecFile: File? = null
13+
private var timer: java.util.Timer? = null
14+
private var cachedAgent: Any? = null
15+
16+
fun setup(context: Context) {
17+
if (!BuildConfig.COVERAGE_ENABLED) return
18+
19+
val agent = try {
20+
Class.forName("org.jacoco.agent.rt.RT")
21+
.getMethod("getAgent")
22+
.invoke(null)
23+
} catch (e: Exception) {
24+
Log.w(TAG, "JaCoCo agent not available — was the app built with coverage?", e)
25+
return
26+
}
27+
cachedAgent = agent
28+
29+
val pid = android.os.Process.myPid()
30+
ecFile = File(context.filesDir, "coverage-$pid.ec")
31+
32+
val app = context.applicationContext as? Application
33+
app?.registerActivityLifecycleCallbacks(object : Application.ActivityLifecycleCallbacks {
34+
override fun onActivityStopped(activity: Activity) = flush()
35+
override fun onActivityCreated(a: Activity, b: Bundle?) {}
36+
override fun onActivityStarted(a: Activity) {}
37+
override fun onActivityResumed(a: Activity) {}
38+
override fun onActivityPaused(a: Activity) {}
39+
override fun onActivitySaveInstanceState(a: Activity, b: Bundle) {}
40+
override fun onActivityDestroyed(a: Activity) {}
41+
})
42+
43+
timer = java.util.Timer("HarnessCoverageFlush", true).also {
44+
it.scheduleAtFixedRate(object : java.util.TimerTask() {
45+
override fun run() = flush()
46+
}, 1000L, 1000L)
47+
}
48+
49+
Log.i(TAG, "pid=$pid, flushing to ${ecFile?.absolutePath}")
50+
}
51+
52+
fun flush() {
53+
val file = ecFile ?: return
54+
val agent = cachedAgent ?: return
55+
try {
56+
val bytes = agent.javaClass
57+
.getMethod("getExecutionData", Boolean::class.javaPrimitiveType)
58+
.invoke(agent, false) as ByteArray
59+
file.writeBytes(bytes)
60+
} catch (e: Exception) {
61+
Log.w(TAG, "Failed to flush coverage data", e)
62+
}
63+
}
64+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
package com.harness.coverage
2+
3+
import android.content.ContentProvider
4+
import android.content.ContentValues
5+
import android.database.Cursor
6+
import android.net.Uri
7+
8+
class CoverageInitProvider : ContentProvider() {
9+
override fun onCreate(): Boolean {
10+
val ctx = context ?: return true
11+
CoverageHelper.setup(ctx)
12+
return true
13+
}
14+
15+
override fun query(u: Uri, p: Array<String>?, s: String?, a: Array<String>?, o: String?): Cursor? = null
16+
override fun getType(uri: Uri): String? = null
17+
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
18+
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<String>?): Int = 0
19+
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = 0
20+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
output=none
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
{
2+
"name": "@react-native-harness/coverage-android",
3+
"description": "Native Android code coverage support for React Native Harness.",
4+
"version": "1.1.0",
5+
"type": "module",
6+
"exports": {
7+
"./package.json": "./package.json",
8+
".": {
9+
"development": "./src/index.ts",
10+
"types": "./dist/index.d.ts",
11+
"import": "./dist/index.js",
12+
"default": "./dist/index.js"
13+
}
14+
},
15+
"files": [
16+
"src",
17+
"dist",
18+
"android",
19+
"scripts",
20+
"!**/__tests__",
21+
"!**/__fixtures__",
22+
"!**/__mocks__",
23+
"!**/.*"
24+
],
25+
"peerDependencies": {
26+
"react-native": "*"
27+
},
28+
"dependencies": {
29+
"tslib": "^2.3.0"
30+
},
31+
"devDependencies": {
32+
"react-native": "*"
33+
},
34+
"license": "MIT",
35+
"homepage": "https://github.com/callstackincubator/react-native-harness",
36+
"author": "React Native Harness contributors"
37+
}

0 commit comments

Comments
 (0)