Skip to content

Commit db070d9

Browse files
committed
test: fix android coverage pull and adb reverse failure e2e flakes
Move Android native coverage pull to a post-Detox CI step with polling, harden Detox adb reverse teardown, and make Jacoco/Codecov best-effort.
1 parent f3c8a8a commit db070d9

7 files changed

Lines changed: 226 additions & 23 deletions

File tree

.github/workflows/tests_e2e_android.yml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -260,10 +260,11 @@ jobs:
260260
$ANDROID_HOME/platform-tools/adb devices
261261
nohup sh -c "$ANDROID_HOME/platform-tools/adb logcat '*:D' > adb-log.txt" &
262262
yarn tests:android:test-cover --headless
263-
yarn tests:android:test:jacoco-report
263+
yarn tests:android:post-e2e-coverage
264264
265265
# https://github.com/codecov/codecov-action/releases
266266
- uses: codecov/codecov-action@e53489f4d376d79066609109e7a95a29eb3740b1 # v7.0.0
267+
continue-on-error: true
267268
with:
268269
verbose: true
269270

.yarn/patches/detox-npm-20.51.0-3e13b6e309.patch

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,3 +146,28 @@ index 10743c87d17de135317e1bac3e65159b9872412f..abbb302307fbd5eabbbff875983f284a
146146
}
147147
}
148148

149+
diff --git a/src/devices/common/drivers/android/exec/ADB.js b/src/devices/common/drivers/android/exec/ADB.js
150+
index 1111111111111111111111111111111111111111..2222222222222222222222222222222222222222 100644
151+
--- a/src/devices/common/drivers/android/exec/ADB.js
152+
+++ b/src/devices/common/drivers/android/exec/ADB.js
153+
@@ -379,7 +379,19 @@ class ADB {
154+
}
155+
156+
async reverseRemove(deviceId, port) {
157+
- return this.adbCmd(deviceId, `reverse --remove tcp:${port}`);
158+
+ try {
159+
+ return await this.adbCmd(deviceId, `reverse --remove tcp:${port}`);
160+
+ } catch (error) {
161+
+ const details = `${error.stderr || ''} ${error.message || ''}`;
162+
+ if (/listener .* not found/i.test(details)) {
163+
+ log.warn(
164+
+ { deviceId, port },
165+
+ 'Ignoring missing adb reverse listener during Detox teardown',
166+
+ );
167+
+ return;
168+
+ }
169+
+ throw error;
170+
+ }
171+
}
172+
173+
async emuKill(deviceId) {

okf-bundle/ci-workflows/android.md

Lines changed: 44 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,46 @@
11
# Android CI workflows
22

3-
TBD — Gradle/Detox emulator reliability, `coverage.ec` pull behavior, and artifact troubleshooting.
3+
## E2E job shape
4+
5+
1. `yarn tests:android:test-cover --headless` — Detox + Jet (pass/fail gate)
6+
2. `yarn tests:android:post-e2e-coverage` — poll/pull `coverage.ec`, Jacoco report (best-effort, never fails the job)
7+
3. Codecov upload — `continue-on-error: true`
8+
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.
10+
11+
## CI failure: Jet 1006 → adb `reverse --remove` mid-run
12+
13+
Observed on [run 27803881448](https://github.com/invertase/react-native-firebase/actions/runs/27803881448):
14+
15+
| Time | Event |
16+
|------|--------|
17+
| Jet attempt 1 | Mocha-remote WS **1006** → Jet grace timer starts |
18+
| Same second | Detox runs `adb reverse --remove tcp:<detox-port>` |
19+
| +15s | Grace expires → Jet exits → orchestration retries |
20+
| Attempt 2 | **2586 tests pass** |
21+
| Teardown | `adb reverse --remove` fails again → Jest FAIL |
22+
23+
**Why reverse cleanup runs mid-run:** Detox `AndroidDriver._launchInstrumentationProcess()` registers a termination handler that always calls `adb reverse --remove` when instrumentation stops:
24+
25+
```342:347:tests/node_modules/detox/src/devices/runtime/drivers/android/AndroidDriver.js
26+
const serverPort = await this._reverseServerPort(adbName);
27+
this.instrumentation.setTerminationFn(async () => {
28+
await this._terminateInstrumentation();
29+
await this.adb.reverseRemove(adbName, serverPort);
30+
});
31+
```
32+
33+
When the Jet/app WebSocket drops (1006), Detox can treat the session as dead and tear down instrumentation **while Jet is still in its 15s reconnect grace**. That triggers `reverseRemove` before the orchestration retry's `terminateApp()`. If the listener was never established or was already removed, adb exits non-zero.
34+
35+
**Mitigation (patched):** `.yarn/patches/detox-npm-20.51.0-*.patch` makes `ADB.reverseRemove` ignore `listener 'tcp:…' not found` during teardown.
36+
37+
**Orchestration retry:** `firebase.test.js` still retries on `RETRYABLE_DISCONNECT`; attempt 2 can pass all tests even when attempt 1's teardown logged adb noise.
38+
39+
## Troubleshooting
40+
41+
| Symptom | Likely cause |
42+
|---------|----------------|
43+
| `[native-coverage] Android native coverage pull failed` | Detox exited before instrumentation wrote `coverage.ec`; post-e2e poll may still recover it |
44+
| Empty Jacoco XML (~235 bytes) | No `.ec` pulled — check post-e2e logs |
45+
| `adb reverse --remove` in Detox logs | Expected on 1006; should be warn-only after Detox patch |
46+
| 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: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -108,13 +108,13 @@ After a Detox/macOS e2e run, expect log lines like `[jet-coverage] WS received N
108108
## Pipeline
109109

110110
1. Gradle enables `testCoverageEnabled` on RNFB Android library modules (`tests/android/build.gradle`).
111-
2. Detox e2e runs; the instrumented app writes `coverage.ec`.
112-
3. When the Jet process exits successfully, `tests/scripts/pull-native-coverage.js` copies the `.ec` file to `tests/android/app/build/output/coverage/emulator_coverage.ec`. **A failed pull logs a warning and does not fail the Detox test** (intermittent on CI when `coverage.ec` is not flushed in time). Jacoco report may be empty for that run.
113-
4. `yarn tests:android:test:jacoco-report` runs `jacocoAndroidTestReport`, producing XML at:
111+
2. Detox e2e runs; the instrumented app writes `coverage.ec` when instrumentation finishes (`DetoxTest.dumpCoverageData()` after `Detox.runTests()` returns).
112+
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).
113+
4. Jacoco XML is produced at:
114114

115115
`tests/android/app/build/reports/jacoco/jacocoAndroidTestReport/jacocoAndroidTestReport.xml`
116116

117-
CI runs steps 3–4 in sequence inside the emulator job.
117+
CI runs step 3 inside the emulator job immediately after `yarn tests:android:test-cover`.
118118

119119
## Jacoco configuration notes
120120

@@ -186,7 +186,7 @@ yarn tests:ios:test-cover-and-process # clean Detox run + process (no --reuse)
186186

187187
# Android (after e2e)
188188
yarn tests:android:test-cover-reuse
189-
yarn tests:android:test:jacoco-report
189+
yarn tests:android:post-e2e-coverage
190190

191191
# Codecov CLI (optional)
192192
.codecov-venv/bin/codecovcli upload-process \
@@ -207,7 +207,8 @@ These must all be true for native iOS coverage to work. If any break, the e2e te
207207
| Profile path set at launch | `AppDelegate``RNFBTestingConfigureCoverageProfilePath()` |
208208
| JS module name matches native export | `RCT_EXPORT_MODULE(RNFBTestingCoverage)` + `NativeModules.RNFBTestingCoverage` in `tests/app.js` |
209209
| 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` |
210+
| Profraw pulled before Detox teardown | `pull-native-coverage.js` on Jet `close` in `firebase.test.js` (iOS only) |
211+
| Android `coverage.ec` pulled after Detox | `yarn tests:android:post-e2e-coverage` in CI / local post-e2e step |
211212
| Fresh profraw processed after e2e | `process-ios-native-coverage.js` (deletes profraw after export) |
212213

213214
# Troubleshooting

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@
4949
"tests:android:test-reuse": "cd tests && yarn detox test --configuration android.emu.debug --reuse",
5050
"tests:android:test-cover": "cd tests && yarn detox test --configuration android.emu.debug --loglevel verbose",
5151
"tests:android:test-cover-reuse": "cd tests && yarn detox test --configuration android.emu.debug --reuse",
52+
"tests:android:pull-native-coverage": "node tests/scripts/pull-native-coverage.js --android-pull",
53+
"tests:android:post-e2e-coverage": "node tests/scripts/pull-native-coverage.js --android-post-e2e",
5254
"tests:android:test:jacoco-report": "cd tests/android && ./gradlew jacocoAndroidTestReport",
5355
"tests:ios:build": "cd tests && yarn detox build --configuration ios.sim.debug",
5456
"tests:ios:build:release": "cd tests && yarn detox build --configuration ios.sim.release",

tests/e2e/firebase.test.js

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ const { spawn } = require('child_process');
1919
const net = require('net');
2020
const path = require('path');
2121

22-
const { pullAndroidCoverage, pullIosCoverage } = require('../scripts/pull-native-coverage');
22+
const { pullIosCoverage } = require('../scripts/pull-native-coverage');
2323

2424
const JET_REMOTE_PORT = parseInt(process.env.JET_REMOTE_PORT || '8090', 10);
2525
const METRO_PORT = parseInt(process.env.JET_METRO_PORT || process.env.RCT_METRO_PORT || '8081', 10);
@@ -326,15 +326,12 @@ describe('Jet Tests', function () {
326326
}
327327
}
328328

329-
try {
330-
if (platform === 'android' && process.platform !== 'win32') {
331-
pullAndroidCoverage(deviceId, { testsDir, softFail: true });
332-
}
333-
if (platform === 'ios' && process.platform === 'darwin') {
329+
if (platform === 'ios' && process.platform === 'darwin') {
330+
try {
334331
pullIosCoverage(deviceId, { testsDir });
332+
} catch (e) {
333+
throw new Error(`Failed to download native coverage data: ${e.message}`);
335334
}
336-
} catch (e) {
337-
throw new Error(`Failed to download native coverage data: ${e.message}`);
338335
}
339336
});
340337

tests/scripts/pull-native-coverage.js

Lines changed: 141 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,69 @@
1-
const { execSync } = require('child_process');
1+
const { execSync, spawnSync } = require('child_process');
22
const fs = require('fs');
33
const path = require('path');
44

55
const TEST_APP_PACKAGE = 'com.invertase.testing';
6+
const ANDROID_COVERAGE_EMU_PATH = `/data/user/0/${TEST_APP_PACKAGE}/files/coverage.ec`;
7+
8+
function getAdbBinary() {
9+
return process.env.ANDROID_HOME
10+
? `${process.env.ANDROID_HOME}/platform-tools/adb`
11+
: 'adb';
12+
}
13+
14+
function resolveAndroidDeviceId(preferredDeviceId) {
15+
if (preferredDeviceId) {
16+
return preferredDeviceId;
17+
}
18+
19+
if (process.env.ANDROID_SERIAL) {
20+
return process.env.ANDROID_SERIAL;
21+
}
22+
23+
const adb = getAdbBinary();
24+
const output = execSync(`${adb} devices`, { encoding: 'utf8' });
25+
const deviceLine = output
26+
.split('\n')
27+
.slice(1)
28+
.map(line => line.trim())
29+
.find(line => line.endsWith('\tdevice'));
30+
31+
if (!deviceLine) {
32+
throw new Error('No online Android device found for native coverage pull');
33+
}
34+
35+
return deviceLine.split('\t')[0];
36+
}
37+
38+
function androidCoverageFileExists(deviceId) {
39+
const adb = getAdbBinary();
40+
const serial = deviceId ? `-s ${deviceId}` : '';
41+
42+
try {
43+
execSync(
44+
`${adb} ${serial} shell "run-as ${TEST_APP_PACKAGE} test -f ${ANDROID_COVERAGE_EMU_PATH}"`,
45+
{ stdio: 'pipe' },
46+
);
47+
return true;
48+
} catch (_) {
49+
return false;
50+
}
51+
}
652

753
function pullAndroidCoverage(deviceId, options = {}) {
854
const { softFail = false, testsDir = path.resolve(__dirname, '..') } = options;
9-
const emuOrig = `/data/user/0/${TEST_APP_PACKAGE}/files/coverage.ec`;
1055
const emuDest = '/data/local/tmp/detox/coverage.ec';
1156
const localDestDir = path.join(testsDir, 'android/app/build/output/coverage');
1257
const localDestFile = path.join(localDestDir, 'emulator_coverage.ec');
13-
const adb = process.env.ANDROID_HOME
14-
? `${process.env.ANDROID_HOME}/platform-tools/adb`
15-
: 'adb';
58+
const adb = getAdbBinary();
59+
const serial = deviceId ? `-s ${deviceId}` : '';
1660

1761
try {
18-
execSync(`${adb} -s ${deviceId} shell "run-as ${TEST_APP_PACKAGE} cat ${emuOrig} > ${emuDest}"`);
62+
execSync(
63+
`${adb} ${serial} shell "run-as ${TEST_APP_PACKAGE} cat ${ANDROID_COVERAGE_EMU_PATH} > ${emuDest}"`,
64+
);
1965
fs.mkdirSync(localDestDir, { recursive: true });
20-
execSync(`${adb} -s ${deviceId} pull ${emuDest} ${localDestFile}`);
66+
execSync(`${adb} ${serial} pull ${emuDest} ${localDestFile}`);
2167
console.log(`Coverage data downloaded to: ${localDestFile}`);
2268
return localDestFile;
2369
} catch (error) {
@@ -30,6 +76,39 @@ function pullAndroidCoverage(deviceId, options = {}) {
3076
}
3177
}
3278

79+
async function pullAndroidCoverageWithRetry(deviceId, options = {}) {
80+
const {
81+
softFail = true,
82+
testsDir = path.resolve(__dirname, '..'),
83+
retries = 15,
84+
intervalMs = 2000,
85+
} = options;
86+
87+
for (let attempt = 1; attempt <= retries; attempt++) {
88+
if (androidCoverageFileExists(deviceId)) {
89+
const pulled = pullAndroidCoverage(deviceId, { softFail: true, testsDir });
90+
if (pulled) {
91+
return pulled;
92+
}
93+
} else if (attempt === 1 || attempt % 5 === 0) {
94+
console.log(
95+
`[native-coverage] Waiting for ${ANDROID_COVERAGE_EMU_PATH} (attempt ${attempt}/${retries})`,
96+
);
97+
}
98+
99+
if (attempt < retries) {
100+
await new Promise(resolve => setTimeout(resolve, intervalMs));
101+
}
102+
}
103+
104+
const message = `Android native coverage file not found after ${retries} attempts`;
105+
if (softFail) {
106+
console.warn(`[native-coverage] ${message}`);
107+
return null;
108+
}
109+
throw new Error(message);
110+
}
111+
33112
function pullIosCoverage(deviceId, options = {}) {
34113
const testsDir = options.testsDir || path.resolve(__dirname, '..');
35114
const localDestDir = path.join(testsDir, 'ios/build/output/coverage');
@@ -63,7 +142,62 @@ function pullIosCoverage(deviceId, options = {}) {
63142
return destPaths;
64143
}
65144

145+
function runJacocoAndroidTestReport() {
146+
const androidDir = path.resolve(__dirname, '../android');
147+
const result = spawnSync('./gradlew', ['jacocoAndroidTestReport'], {
148+
cwd: androidDir,
149+
stdio: 'inherit',
150+
shell: true,
151+
});
152+
153+
if (result.status !== 0) {
154+
console.warn(
155+
`[native-coverage] jacocoAndroidTestReport exited with status ${result.status ?? 'unknown'}`,
156+
);
157+
return false;
158+
}
159+
160+
return true;
161+
}
162+
163+
async function main() {
164+
const args = process.argv.slice(2);
165+
166+
if (args.includes('--android-pull')) {
167+
const deviceId = resolveAndroidDeviceId();
168+
console.log(`[native-coverage] Pulling Android coverage from ${deviceId}`);
169+
await pullAndroidCoverageWithRetry(deviceId, { softFail: true });
170+
return;
171+
}
172+
173+
if (args.includes('--android-post-e2e')) {
174+
const deviceId = resolveAndroidDeviceId();
175+
console.log(`[native-coverage] Post-e2e Android coverage on ${deviceId}`);
176+
const pulled = await pullAndroidCoverageWithRetry(deviceId, { softFail: true });
177+
runJacocoAndroidTestReport();
178+
if (!pulled) {
179+
console.warn('[native-coverage] Jacoco report may be empty (no coverage.ec pulled)');
180+
}
181+
return;
182+
}
183+
184+
console.error(
185+
'Usage: node tests/scripts/pull-native-coverage.js --android-pull|--android-post-e2e',
186+
);
187+
process.exit(1);
188+
}
189+
190+
if (require.main === module) {
191+
main().catch(error => {
192+
console.warn(`[native-coverage] ${error.message}`);
193+
process.exit(0);
194+
});
195+
}
196+
66197
module.exports = {
67198
pullAndroidCoverage,
199+
pullAndroidCoverageWithRetry,
68200
pullIosCoverage,
201+
resolveAndroidDeviceId,
202+
runJacocoAndroidTestReport,
69203
};

0 commit comments

Comments
 (0)