Skip to content

Commit e24d7e6

Browse files
committed
test: repair native coverage android dump / ios upload
1 parent de48420 commit e24d7e6

8 files changed

Lines changed: 117 additions & 63 deletions

File tree

okf-bundle/ci-workflows/android.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
2. `yarn tests:android:post-e2e-coverage` — poll/pull `coverage.ec`, Jacoco report (best-effort, never fails the job)
77
3. Codecov upload — `continue-on-error: true`
88

9-
Native Android coverage is **not** pulled inside `tests/e2e/firebase.test.js`. Pulling mid-Detox ran before `DetoxTest.dumpCoverageData()` and routinely missed the file.
9+
Native Android coverage is **not** pulled inside `tests/e2e/firebase.test.js`. The Jet `after` hook in `tests/app.js` calls `RNFBTestingCoverage.flush()` in the **app process** to write `coverage.ec` before Detox SIGINTs instrumentation. Post-e2e pull runs after Detox exits.
1010

1111
## CI failure: Jet 1006 → adb `reverse --remove` mid-run
1212

@@ -40,7 +40,7 @@ When the Jet/app WebSocket drops (1006), Detox can treat the session as dead and
4040

4141
| Symptom | Likely cause |
4242
|---------|----------------|
43-
| `[native-coverage] Android native coverage pull failed` | Detox exited before instrumentation wrote `coverage.ec`; post-e2e poll may still recover it |
43+
| `[native-coverage] Android native coverage file not found after N attempts` | App-process flush did not run or failed; check Jet log for `[native-coverage] flushing android coverage` |
4444
| Empty Jacoco XML (~235 bytes) | No `.ec` pulled — check post-e2e logs |
4545
| `adb reverse --remove` in Detox logs | Expected on 1006; should be warn-only after Detox patch |
4646
| Detox red, tests green in log | Pre-patch: teardown adb error; re-run or check patch applied |

okf-bundle/testing/coverage-design.md

Lines changed: 27 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,18 @@ flowchart LR
3333
N --> T2[coverage/lcov.info]
3434
end
3535
subgraph android_native [E2e Android native]
36-
D1[Detox e2e] --> EC[coverage.ec in app]
36+
D1[Detox e2e] --> FLA[RNFBTestingCoverage.flush]
37+
FLA --> EC[coverage.ec in app filesDir]
3738
EC --> P1[pull-native-coverage.js]
3839
P1 --> J1R[jacocoAndroidTestReport]
3940
J1R --> AX[jacoco XML]
4041
end
4142
subgraph ios_native [E2e iOS native]
42-
D2[Detox e2e] --> FL[RNFBTestingCoverage.flush]
43-
FL --> PR[coverage.profraw in Documents]
43+
D2[Detox e2e] --> FLI[RNFBTestingCoverage.flush]
44+
FLI --> PR[coverage.profraw in Documents]
4445
PR --> P2[pull-native-coverage.js]
4546
P2 --> LLVM[process-ios-native-coverage.js]
46-
LLVM --> I2[coverage/ios-native.lcov.info]
47+
LLVM --> I2[coverage/ios-native/lcov.info]
4748
end
4849
J2 --> C[Codecov]
4950
T2 --> C
@@ -108,14 +109,18 @@ After a Detox/macOS e2e run, expect log lines like `[jet-coverage] WS received N
108109
## Pipeline
109110

110111
1. Gradle enables `testCoverageEnabled` on RNFB Android library modules (`tests/android/build.gradle`).
111-
2. Detox e2e runs; the instrumented app writes `coverage.ec` when instrumentation finishes (`DetoxTest.dumpCoverageData()` after `Detox.runTests()` returns).
112+
2. Detox e2e runs Jet tests in the instrumented app. After all Mocha tests complete, the Jet `after` hook in `tests/app.js` calls `NativeModules.RNFBTestingCoverage.flush()` (`RNFBTestingCoverageModule` in the **app** process). This writes `coverage.ec` to the app `filesDir` **before** Detox SIGINTs the instrumentation process.
112113
3. **After Detox exits**, `yarn tests:android:post-e2e-coverage` (CI) or `yarn tests:android:pull-native-coverage` polls for `coverage.ec`, pulls it to `tests/android/app/build/output/coverage/emulator_coverage.ec`, then runs `jacocoAndroidTestReport`. A missing file logs a warning and does **not** fail the Detox test or the CI job (`continue-on-error` on Codecov upload).
113114
4. Jacoco XML is produced at:
114115

115116
`tests/android/app/build/reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml`
116117

117118
CI runs step 3 inside the emulator job immediately after `yarn tests:android:test-cover`.
118119

120+
## Why flush runs in the app process
121+
122+
Detox sends **SIGINT** to the instrumentation process as soon as the Jet session ends. A Jacoco dump placed after `Detox.runTests()` in `DetoxTest.java` never runs. The app-process native module mirrors the iOS LLVM flush pattern: JS test completion → native module → file on disk → post-e2e pull.
123+
119124
## Jacoco configuration notes
120125

121126
- Class directories must use the AGP 8.x path: `build/intermediates/javac/debug/compileDebugJavaWithJavac/classes` (not legacy `.../debug/classes`).
@@ -141,7 +146,7 @@ CI runs step 3 inside the emulator job immediately after `yarn tests:android:tes
141146
- merges `.profraw` from `tests/ios/build/output/coverage/` (Detox) and optionally `Build/ProfileData/` (`xcodebuild test` only — not used by Detox today)
142147
- exports lcov with `xcrun llvm-cov export -format=lcov` against the main app binary (statically linked RNFB code), writing to a temp file (large reports exceed Node stdout buffer limits)
143148
- streams and rewrites `SF:` paths to repo-relative `packages/**` paths
144-
- writes **`coverage/ios-native.lcov.info`**
149+
- writes **`coverage/ios-native/lcov.info`**
145150
- **deletes the processed `.profraw` files** so a missing file on the next run is a clear signal that e2e did not produce fresh native coverage
146151

147152
## Objective-C and Swift
@@ -156,20 +161,20 @@ The Podfile `post_install` coverage flags are temporary. When native dependencie
156161

157162
# Codecov uploads (CI)
158163

159-
CI uses [codecov-action](https://github.com/codecov/codecov-action) v6 with `verbose: true`. It discovers coverage files under the repo, including:
164+
CI uses [codecov-action](https://github.com/codecov/codecov-action) v7 with `verbose: true`. It auto-discovers coverage files under the repo, including:
160165

161166
| Workflow | Artifacts |
162167
|----------|-----------|
163168
| `tests_jest.yml` | `coverage/lcov.info`, `coverage-final.json`, `clover.xml` |
164169
| `tests_e2e_android.yml` | `coverage/lcov.info` + Jacoco XML |
165170
| `tests_e2e_other.yml` | `coverage/lcov.info` (macOS TS) |
166-
| `tests_e2e_ios.yml` (debug) | `coverage/lcov.info` + `coverage/ios-native.lcov.info` |
171+
| `tests_e2e_ios.yml` (debug) | `coverage/lcov.info` + `coverage/ios-native/lcov.info` |
167172

168173
The iOS workflow runs `yarn tests:ios:test:process-coverage` after Detox (`if: always()`, `continue-on-error: true` for now). The process step exits 1 when profraw is missing.
169174

170175
## File naming
171176

172-
The repo standard for JavaScript lcov is `coverage/lcov.info`. iOS native uses **`coverage/ios-native.lcov.info`**. Codecov detects **lcov format by file content**, not only by the exact name `lcov.info`.
177+
The repo standard for JavaScript lcov is **`coverage/lcov.info`**. iOS native lcov is **`coverage/ios-native/lcov.info`** — the basename `lcov.info` is required for codecov-action auto-discovery (files like `ios-native.lcov.info` are not matched). Android native coverage is **Jacoco XML**, not lcov; codecov discovers `jacoco*.xml` once the report is generated.
173178

174179
Check the Codecov commit **Uploads** tab for **Processed** vs **Unusable** per upload — that is the authoritative signal, not small project percentage deltas.
175180

@@ -192,24 +197,25 @@ yarn tests:android:post-e2e-coverage
192197
.codecov-venv/bin/codecovcli upload-process \
193198
-t "$CODECOV_TOKEN" -r invertase/react-native-firebase \
194199
--git-service github -C "$(git rev-parse HEAD)" -B "$(git branch --show-current)" \
195-
-f coverage/ios-native.lcov.info -n local-ios-native --disable-search
200+
-f coverage/ios-native/lcov.info -n local-ios-native --disable-search
196201
```
197202

198203
Metro must be running (`yarn tests:packager:jet`) for Detox e2e.
199204

200205
# Critical invariants
201206

202-
These must all be true for native iOS coverage to work. If any break, the e2e test should fail (not silently upload stale data).
207+
These must all be true for native coverage to work. If any break, the e2e test should fail (not silently upload stale data).
203208

204209
| Invariant | Where enforced |
205210
|-----------|----------------|
206-
| App built with LLVM profile flags | `Podfile` `post_install` (run `pod install`) |
207-
| Profile path set at launch | `AppDelegate``RNFBTestingConfigureCoverageProfilePath()` |
208-
| JS module name matches native export | `RCT_EXPORT_MODULE(RNFBTestingCoverage)` + `NativeModules.RNFBTestingCoverage` in `tests/app.js` |
209-
| Flush runs after Mocha tests | Jet `after` hook in `tests/app.js` |
210-
| Profraw pulled before Detox teardown | `pull-native-coverage.js` on Jet `close` in `firebase.test.js` (iOS only) |
211+
| App built with LLVM profile flags (iOS) | `Podfile` `post_install` (run `pod install`) |
212+
| Profile path set at launch (iOS) | `AppDelegate``RNFBTestingConfigureCoverageProfilePath()` |
213+
| App built with Jacoco instrumentation (Android) | `testCoverageEnabled` in `tests/android/build.gradle` |
214+
| JS module name matches native export | `RNFBTestingCoverage` on iOS + Android; `NativeModules.RNFBTestingCoverage` in `tests/app.js` |
215+
| Native flush runs after Mocha tests | Jet `after` hook in `tests/app.js` (iOS + Android) |
216+
| Profraw pulled before Detox teardown (iOS) | `pull-native-coverage.js` on Jet `close` in `firebase.test.js` |
211217
| Android `coverage.ec` pulled after Detox | `yarn tests:android:post-e2e-coverage` in CI / local post-e2e step |
212-
| Fresh profraw processed after e2e | `process-ios-native-coverage.js` (deletes profraw after export) |
218+
| Fresh profraw processed after e2e (iOS) | `process-ios-native-coverage.js` (deletes profraw after export) |
213219

214220
# Troubleshooting
215221

@@ -219,10 +225,13 @@ These must all be true for native iOS coverage to work. If any break, the e2e te
219225
| No profraw after e2e; test still passes (old behaviour) | Pull in `afterAll` after `detox.cleanup()`, or wrong module name | Pull on Jet `close`; verify `RNFBTestingCoverage` export name |
220226
| Stale profraw uploaded | Re-processed old file without re-running e2e | Process step deletes profraw after export; missing file + exit 1 on next process |
221227
| `process-ios-native-coverage` succeeds but no `packages/` hits | Wrong binary / not instrumented | Rebuild with `yarn tests:ios:build`; check Podfile flags |
222-
| Empty Jacoco XML (~235 bytes) | AGP 8 class path, missing `src/reactnative/java`, or no `coverage.ec` pulled | See `jacocoAndroidTestReport`; check `[native-coverage] Android native coverage pull failed` warning |
228+
| Empty Jacoco XML (~235 bytes) | AGP 8 class path, missing `src/reactnative/java`, or no `coverage.ec` pulled | See `jacocoAndroidTestReport`; check post-e2e poll logs |
229+
| Android `coverage.ec` missing after passing e2e | Detox SIGINT before instrumentation `@Test` tail; flush not called from app | Verify `[native-coverage] flushing android coverage` in Jet log; check `RNFBTestingCoverageModule` registered in `MainApplication` |
230+
| Jet `after` hook logs coverage not enabled | Non-instrumented build (local release or no Jacoco/LLVM flags) | Expected on `tests:android:test` without debug Jacoco; use debug e2e builds for native coverage; hook catches errors so tests still pass |
223231
| iOS link: `swiftCompatibility56` undefined | Profile link flags applied to all Pods | Restrict `OTHER_LDFLAGS` profile flags to app target; RNFB pods compile-only |
224232
| No `[jet-coverage] WS received` lines | Patches not applied | `yarn install` from repo root; check `.yarn/patches/` |
225233
| NYC summary missing / empty `lcov.info` | Jet not run from `tests/` cwd | Detox spawns `yarn jet` inside `tests/`; macOS uses `cd tests && npx jet` |
234+
| Codecov missing iOS native files | Output not named `lcov.info` | Use `coverage/ios-native/lcov.info` (not `coverage/ios-native.lcov.info`) |
226235
| Codecov upload **Unusable** | Wrong `SF:` paths | Confirm path rewrite in `process-ios-native-coverage.js`; check Uploads tab message |
227236

228237
# Future cleanups (non-blocking)
Lines changed: 0 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,9 @@
11
package com.invertase.testing;
22

3-
import java.io.File;
4-
import java.lang.reflect.Method;
5-
6-
import androidx.test.platform.app.InstrumentationRegistry;
73
import androidx.test.ext.junit.runners.AndroidJUnit4;
84
import androidx.test.filters.LargeTest;
95
import androidx.test.rule.ActivityTestRule;
106

11-
import com.google.firebase.appcheck.debug.testing.DebugAppCheckTestHelper;
12-
137
import com.wix.detox.Detox;
148
import com.wix.detox.config.DetoxConfig;
159

@@ -29,33 +23,11 @@ public class DetoxTest {
2923

3024
@Test
3125
public void runDetoxTests() {
32-
3326
DetoxConfig detoxConfig = new DetoxConfig();
3427
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90;
3528
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60;
3629
detoxConfig.rnContextLoadTimeoutSec = 2400;
3730

3831
Detox.runTests(mActivityRule, detoxConfig);
39-
dumpCoverageData();
40-
}
41-
42-
// If you send '-e coverage' as part of the instrumentation command line, it should dump coverage as the Instrumentation finishes.
43-
// However, there is a long-standing UIAutomator bug that crashes the process during Instrumentation.finish, before dumping coverage.
44-
// It is trivial to dump it ourselves though, using the same mechanism the AndroidJUnit4Runner woud use
45-
// Code reference: https://android.googlesource.com/platform/frameworks/testing/+/refs/heads/master/support/src/android/support/test/internal/runner/listener/CoverageListener.java#68
46-
// UIAutomator issue: https://github.com/android/testing-samples/issues/89
47-
private void dumpCoverageData() {
48-
String coverageFilePath = InstrumentationRegistry.getInstrumentation().getTargetContext().getFilesDir().getAbsolutePath() +
49-
File.separator + "coverage.ec";
50-
File coverageFile = new File(coverageFilePath);
51-
try {
52-
Class<?> emmaRTClass = Class.forName("com.vladium.emma.rt.RT");
53-
Method dumpCoverageMethod = emmaRTClass.getMethod("dumpCoverageData",
54-
coverageFile.getClass(), boolean.class, boolean.class);
55-
dumpCoverageMethod.invoke(null, coverageFile, false, false);
56-
System.out.println("Dumped code coverage data to " + coverageFilePath);
57-
} catch (Exception e) {
58-
System.err.println("Unable to dump coverage report: " + e);
59-
}
6032
}
6133
}

tests/android/app/src/main/java/com/invertase/testing/MainApplication.kt

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ class MainApplication : Application(), ReactApplication {
2121

2222
override fun getPackages(): List<ReactPackage> =
2323
PackageList(this).packages.apply {
24-
// Packages that cannot be autolinked yet can be added manually here, for example:
25-
// add(MyReactNativePackage())
24+
add(RNFBTestingCoveragePackage())
2625
}
2726

2827
override fun getJSMainModuleName(): String = "index"
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
package com.invertase.testing
2+
3+
import android.util.Log
4+
import com.facebook.react.bridge.Promise
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.bridge.ReactContextBaseJavaModule
7+
import com.facebook.react.bridge.ReactMethod
8+
import java.io.File
9+
10+
class RNFBTestingCoverageModule(reactContext: ReactApplicationContext) :
11+
ReactContextBaseJavaModule(reactContext) {
12+
13+
override fun getName(): String = "RNFBTestingCoverage"
14+
15+
@ReactMethod
16+
fun flush(promise: Promise) {
17+
try {
18+
val coverageFile = File(reactApplicationContext.filesDir, "coverage.ec")
19+
val emmaRT = Class.forName("com.vladium.emma.rt.RT")
20+
val dump =
21+
emmaRT.getMethod(
22+
"dumpCoverageData",
23+
File::class.java,
24+
Boolean::class.javaPrimitiveType,
25+
Boolean::class.javaPrimitiveType,
26+
)
27+
dump.invoke(null, coverageFile, false, false)
28+
Log.i(
29+
TAG,
30+
"[native-coverage] flushed Jacoco coverage to ${coverageFile.absolutePath}",
31+
)
32+
promise.resolve(coverageFile.absolutePath)
33+
} catch (e: ClassNotFoundException) {
34+
Log.w(
35+
TAG,
36+
"[native-coverage] Jacoco/Emma RT class not found; coverage is likely not enabled in this build",
37+
)
38+
promise.reject("coverage_not_enabled", "Jacoco/Emma RT class not found", e)
39+
} catch (e: Exception) {
40+
Log.e(TAG, "[native-coverage] flush failed", e)
41+
promise.reject("coverage_flush_failed", "Failed to dump Jacoco coverage data", e)
42+
}
43+
}
44+
45+
companion object {
46+
private const val TAG = "RNFBTestingCoverage"
47+
}
48+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package com.invertase.testing
2+
3+
import com.facebook.react.ReactPackage
4+
import com.facebook.react.bridge.NativeModule
5+
import com.facebook.react.bridge.ReactApplicationContext
6+
import com.facebook.react.uimanager.ViewManager
7+
8+
class RNFBTestingCoveragePackage : ReactPackage {
9+
override fun createNativeModules(reactContext: ReactApplicationContext): List<NativeModule> =
10+
listOf(RNFBTestingCoverageModule(reactContext))
11+
12+
override fun createViewManagers(reactContext: ReactApplicationContext): List<ViewManager<*, *>> =
13+
emptyList()
14+
}

tests/app.js

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -249,17 +249,29 @@ function loadTests(_) {
249249
}
250250

251251
after(async function flushNativeCoverageProfile() {
252-
if (Platform.OS === 'ios') {
253-
const { NativeModules } = require('react-native');
254-
const coverageModule = NativeModules.RNFBTestingCoverage;
255-
if (coverageModule?.flush) {
256-
console.log('[ios-native-coverage] flushing LLVM profile from Jet after hook');
252+
if (Platform.OS !== 'ios' && Platform.OS !== 'android') {
253+
return;
254+
}
255+
256+
const { NativeModules } = require('react-native');
257+
const coverageModule = NativeModules.RNFBTestingCoverage;
258+
if (coverageModule?.flush) {
259+
console.log(`[native-coverage] flushing ${Platform.OS} coverage from Jet after hook`);
260+
try {
257261
await coverageModule.flush();
258-
} else {
259-
console.warn(
260-
'[ios-native-coverage] RNFBTestingCoverage native module not available; skipping flush',
261-
);
262+
} catch (error) {
263+
if (error?.code === 'coverage_not_enabled') {
264+
console.warn(
265+
`[native-coverage] ${Platform.OS} coverage not enabled in this build; skipping flush`,
266+
);
267+
} else {
268+
console.error(`[native-coverage] failed to flush ${Platform.OS} coverage:`, error);
269+
}
262270
}
271+
} else {
272+
console.warn(
273+
'[native-coverage] RNFBTestingCoverage native module not available; skipping flush',
274+
);
263275
}
264276
});
265277
});

tests/scripts/process-ios-native-coverage.js

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ function parseArgs(argv) {
1717
derivedData: path.join(testsDir, 'ios/build'),
1818
configuration: 'Debug',
1919
appName: 'testing',
20-
output: path.join(repoRoot, 'coverage/ios-native.lcov.info'),
20+
output: path.join(repoRoot, 'coverage/ios-native/lcov.info'),
2121
};
2222

2323
for (let i = 0; i < argv.length; i += 1) {
@@ -42,7 +42,7 @@ Options:
4242
--derived-data <path> Detox/Xcode derived data (default: tests/ios/build)
4343
--configuration <name> Xcode configuration (default: Debug)
4444
--app-name <name> App product name (default: testing)
45-
--output <path> lcov output path (default: coverage/ios-native.lcov.info)
45+
--output <path> lcov output path (default: coverage/ios-native/lcov.info)
4646
`);
4747
process.exit(0);
4848
}
@@ -182,7 +182,7 @@ async function main() {
182182

183183
fs.mkdirSync(path.dirname(options.output), { recursive: true });
184184

185-
const profdataPath = path.join(path.dirname(options.output), 'ios-native.profdata');
185+
const profdataPath = path.join(path.dirname(options.output), 'profdata');
186186
runOrThrow('xcrun', [
187187
'llvm-profdata',
188188
'merge',
@@ -192,7 +192,7 @@ async function main() {
192192
profdataPath,
193193
]);
194194

195-
const rawLcovPath = path.join(path.dirname(options.output), 'ios-native.lcov.raw');
195+
const rawLcovPath = path.join(path.dirname(options.output), 'lcov.raw');
196196
try {
197197
runToFileOrThrow('xcrun', [
198198
'llvm-cov',

0 commit comments

Comments
 (0)