Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .nx/version-plans/add-experimental-android-native-coverage.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.'),
Expand Down
107 changes: 107 additions & 0 deletions packages/coverage-android/README.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<provider
android:name="com.harness.coverage.CoverageInitProvider"
android:authorities="${applicationId}.harness_coverage"
android:exported="false"
android:initOrder="999" />
</application>
</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
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) {
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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<String>?, s: String?, a: Array<String>?, 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<String>?): Int = 0
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<String>?): Int = 0
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
output=none
37 changes: 37 additions & 0 deletions packages/coverage-android/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
Loading
Loading