Skip to content

feat: Dart coverage collection that survives BrowserStack Espresso runs#3066

Open
fylyppo wants to merge 1 commit into
masterfrom
feat/bs-coverage
Open

feat: Dart coverage collection that survives BrowserStack Espresso runs#3066
fylyppo wants to merge 1 commit into
masterfrom
feat/bs-coverage

Conversation

@fylyppo
Copy link
Copy Markdown
Collaborator

@fylyppo fylyppo commented May 14, 2026

Problem

patrol test collects 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/.../coverage returns 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 SessionInfo blocks inside the same .ec file 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 in PatrolBinding.tearDown:

  1. Get the VM service URI via dart:developer Service.getInfo().
  2. Connect via package:vm_service; iterate isolates, call getSourceReport(['Coverage'], forceCompile: true, reportLines: true).
  3. Filter by URI (default deny list excludes dart:/package:flutter*/package:patrol*; opt-in allowlist via PATROL_BS_COVERAGE_PACKAGES=<regexp>).
  4. Format as LCOV; write to <filesDir>/patrol_coverage/<sanitized-test-name>.lcov via path_provider.

Android side — PatrolJUnitRunner + BrowserStackCoverage.kt

  • onCreate captures coverageFile arg and disables AGP's auto-dump (coverage=false). AGP performs the dump inside finish(), which races against process teardown — the test process is killed before the append window closes. Removing AGP from the picture sidesteps the race.
  • After every runDartTest returns (process fully alive, Dart side has already written its LCOV), we drive the dump ourselves: reflection on org.jacoco.agent.rt.RT.getAgent().getExecutionData(false) to get the JaCoCo bytes, write them to coverageFile, then call BrowserStackCoverage.appendDartCoverage.
  • BrowserStackCoverage.kt reads every *.lcov from <filesDir>/patrol_coverage/, concatenates, base64-chunks to fit JaCoCo's 65535-byte writeUTF cap, and appends one SessionInfo block per chunk with id PATROL_DART_COV:<seq>:<total>:<b64>.

Host side — patrol bs pull-coverage

New subcommand that:

  • GET /app-automate/espresso/v2/builds/{build_id}/sessions/{session_id}/coverage
  • walks the binary block stream (CompactDataOutput-aware: 2-byte modified-UTF-8 length prefix, VarInt boolean array length, big-endian longs)
  • writes a clean jacoco.exec (openable in Android Studio → Analyze → Show Coverage Data)
  • reassembles the prefixed blocks into patrol_lcov.info (consumable by genhtml, Codecov, etc.)

Result

Single patrol test on Samsung Galaxy S24 Ultra-14.0:

Before After
.ec size 311 KB 2.2 MB
JaCoCo class probes 4 296 4 272
Dart SessionInfo chunks 0 30
Dart source files in LCOV 2 493
Dart hit lines 7 894

How to use

Host project (the one importing patrol):

// app-level build.gradle
plugins { id "jacoco" }
android {
    buildTypes.debug { testCoverageEnabled = true }
}

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": false

After the run:

patrol bs pull-coverage \
  --build-id <BS build id> \
  --session-id <BS session id> \
  -o coverage/
# → coverage/jacoco.exec  (Java/Kotlin)
# → coverage/patrol_lcov.info  (Dart)

Limitations / follow-ups

  • Requires useOrchestrator: false in the BS payload. With Android Test Orchestrator each test runs in a forked process that loses /sdcard write 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: true on getSourceReport is expensive for large Flutter apps. The allowlist regexp keeps the cost predictable.
  • iOS coverage is out of scope here — BS App Automate Espresso coverage is Android-only.

Test plan

  • End-to-end on real BS device, validated .ec parses cleanly in Android Studio and genhtml patrol_lcov.info produces a navigable Dart report.
  • Splitter parses both standard and patrol-injected blocks without losing JaCoCo probes.
  • Behaves as a no-op when PATROL_BS_COVERAGE is unset (no perf overhead in normal runs).

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.
@github-actions github-actions Bot added package: patrol Related to the patrol package (native automation, test bundling) package: patrol_cli Related to the patrol_cli package labels May 14, 2026
Copy link
Copy Markdown
Contributor

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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");

Comment on lines +136 to +140
final patterns = _packagesEnv
.split(',')
.where((s) => s.isNotEmpty)
.map(RegExp.new)
.toList();
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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));
    }

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

package: patrol_cli Related to the patrol_cli package package: patrol Related to the patrol package (native automation, test bundling)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant