feat: Dart coverage collection that survives BrowserStack Espresso runs#3066
feat: Dart coverage collection that survives BrowserStack Espresso runs#3066fylyppo wants to merge 1 commit into
Conversation
Existing `patrol test` collects Dart coverage by port-forwarding to the VM
service via ADB. That path doesn't work on BrowserStack — there is no ADB
back to the cloud device, so coverage from the actual test execution is
never gathered and `GET /espresso/v2/.../coverage` returns only what
JaCoCo dumped during patrol's test-discovery boot.
This change wires up a BS-friendly path:
* `BrowserStackCoverage` (Dart): when `PATROL_BS_COVERAGE=true` is set
via `--dart-define`, after each test patrol's `PatrolBinding`
connects to the in-process VM service, calls `getSourceReport`
filtered by `PATROL_BS_COVERAGE_PACKAGES` (regexp), formats the
hitmap as LCOV, and writes it to `<filesDir>/patrol_coverage/`.
* `PatrolJUnitRunner` (Android): in `onCreate` we capture the
`coverageFile` arg and disable AGP's auto-dump (`coverage=false`)
because AGP performs that dump inside `finish()`, which races
against process teardown. After every `runDartTest` returns we
drive the dump ourselves via reflection on `org.jacoco.agent.rt.RT`
and then append the Dart LCOV blocks (`BrowserStackCoverage.kt`).
* `BrowserStackCoverage.kt` (Kotlin): encodes the LCOV as a series of
JaCoCo `SessionInfo` blocks (binary-compatible with
`ExecutionDataWriter`), base64-chunked to fit the 65535-byte
`writeUTF` cap and prefixed with `PATROL_DART_COV:` so the host
splitter recognises them.
* `patrol bs pull-coverage` (CLI): downloads the merged `.ec` from
BrowserStack, walks the binary block stream, writes a clean
`jacoco.exec` (openable in Android Studio) and reassembles the
Dart blocks into `patrol_lcov.info` (consumable by `genhtml`,
Codecov, etc).
Verified end-to-end on a Samsung Galaxy S24 Ultra-14.0 BS device: 2.2 MB
merged `.ec`, 30 Dart chunks, ~2500 covered source files, ~7900 hit lines.
There was a problem hiding this comment.
Code Review
This pull request introduces BrowserStack-friendly Dart coverage collection for Patrol, allowing Dart line coverage to be merged into JaCoCo reports. The implementation includes an Android bridge for coverage data merging, a Dart-side collector using the VM service, and a new CLI subcommand patrol bs pull-coverage to retrieve and process these reports. Feedback highlights a potential compatibility issue with getDataDir() on Android devices below API level 24 and suggests optimizing regex compilation in the Dart coverage logic to improve performance.
| if (coverageFilePath != null && !coverageFilePath.isEmpty()) { | ||
| return new File(coverageFilePath); | ||
| } | ||
| return new File(getTargetContext().getDataDir(), "coverage.ec"); |
There was a problem hiding this comment.
The getDataDir() method was introduced in API level 24. If this runner is executed on older devices (e.g., API 21, which is common for Flutter apps), this call will result in a NoSuchMethodError. Consider adding a version check and providing a fallback for older API levels.
File dataDir = android.os.Build.VERSION.SDK_INT >= 24
? getTargetContext().getDataDir()
: getTargetContext().getFilesDir().getParentFile();
return new File(dataDir, "coverage.ec");| final patterns = _packagesEnv | ||
| .split(',') | ||
| .where((s) => s.isNotEmpty) | ||
| .map(RegExp.new) | ||
| .toList(); |
There was a problem hiding this comment.
Compiling regex patterns on every call to _shouldInclude is inefficient, especially since this method is called for every range in the source report. Consider moving the regex compilation to a static field so they are compiled only once.
static final List<RegExp> _patterns = _packagesEnv
.split(',')
.where((s) => s.isNotEmpty)
.map(RegExp.new)
.toList();
static bool _shouldInclude(String uri) {
if (_patterns.isNotEmpty) {
return _patterns.any((p) => p.hasMatch(uri));
}
Problem
patrol testcollects Dart coverage by port-forwarding to the VM service via ADB. On BrowserStack there is no ADB back to the cloud device, so coverage from the actual test execution is never gathered.GET /espresso/v2/.../coveragereturns only what JaCoCo dumped during patrol's test-discovery boot — close to useless.Approach
Take over the JaCoCo dump and inject Dart coverage as extra
SessionInfoblocks inside the same.ecfile BrowserStack already retrieves. No new endpoints, no logcat scraping, no out-of-band file transfers.Dart side —
BrowserStackCoverage(packages/patrol/lib/src/bs_coverage.dart)Active when
--dart-define=PATROL_BS_COVERAGE=true. After every test inPatrolBinding.tearDown:dart:developer Service.getInfo().package:vm_service; iterate isolates, callgetSourceReport(['Coverage'], forceCompile: true, reportLines: true).dart:/package:flutter*/package:patrol*; opt-in allowlist viaPATROL_BS_COVERAGE_PACKAGES=<regexp>).<filesDir>/patrol_coverage/<sanitized-test-name>.lcovviapath_provider.Android side —
PatrolJUnitRunner+BrowserStackCoverage.ktonCreatecapturescoverageFilearg and disables AGP's auto-dump (coverage=false). AGP performs the dump insidefinish(), which races against process teardown — the test process is killed before the append window closes. Removing AGP from the picture sidesteps the race.runDartTestreturns (process fully alive, Dart side has already written its LCOV), we drive the dump ourselves: reflection onorg.jacoco.agent.rt.RT.getAgent().getExecutionData(false)to get the JaCoCo bytes, write them tocoverageFile, then callBrowserStackCoverage.appendDartCoverage.BrowserStackCoverage.ktreads every*.lcovfrom<filesDir>/patrol_coverage/, concatenates, base64-chunks to fit JaCoCo's 65535-bytewriteUTFcap, and appends oneSessionInfoblock per chunk with idPATROL_DART_COV:<seq>:<total>:<b64>.Host side —
patrol bs pull-coverageNew subcommand that:
GET /app-automate/espresso/v2/builds/{build_id}/sessions/{session_id}/coveragejacoco.exec(openable in Android Studio → Analyze → Show Coverage Data)patrol_lcov.info(consumable bygenhtml, Codecov, etc.)Result
Single patrol test on Samsung Galaxy S24 Ultra-14.0:
.ecsizeSessionInfochunksHow to use
Host project (the one importing patrol):
Test invocation (custom test runner script):
patrol build android --dart-define=PATROL_BS_COVERAGE=true \ --dart-define='PATROL_BS_COVERAGE_PACKAGES=^package:myapp/' # upload + schedule on BS with "coverage": true and "useOrchestrator": falseAfter the run:
Limitations / follow-ups
useOrchestrator: falsein the BS payload. With Android Test Orchestrator each test runs in a forked process that loses/sdcardwrite access; without it AGP's coverage dump still works but the appended file would be empty for the merged Dart blocks. Worth revisiting later with a per-test private file + post-orchestrator merge step.forceCompile: trueongetSourceReportis expensive for large Flutter apps. The allowlist regexp keeps the cost predictable.Test plan
.ecparses cleanly in Android Studio andgenhtml patrol_lcov.infoproduces a navigable Dart report.PATROL_BS_COVERAGEis unset (no perf overhead in normal runs).