Skip to content
Open
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
4 changes: 4 additions & 0 deletions packages/patrol/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## Unreleased

- Add BrowserStack-friendly Dart coverage collection. With `--dart-define=PATROL_BS_COVERAGE=true` (and optional `PATROL_BS_COVERAGE_PACKAGES=<regexp>`), patrol gathers Dart line coverage from the running VM service after each test and the Android test runner merges it into the JaCoCo `.exec` that BrowserStack collects.

## 4.5.0

- Fix `appId` not being passed down on `$.platform.mobile.enterText` and `$.platform.mobile.enterTextByIndex` (#2992)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
package pl.leancode.patrol

import android.content.Context
import java.io.DataOutputStream
import java.io.File
import java.io.FileOutputStream

/**
* Bridges Dart coverage (gathered on-device by patrol's Dart side) into the
* JaCoCo `.ec` file that BrowserStack's coverage pipeline picks up.
*
* Strategy: patrol's Dart side writes one or more LCOV-formatted files into
* `<filesDir>/patrol_coverage/`. After the JaCoCo agent finishes its own dump,
* we read those files, base64-chunk the bytes to fit the 65535-byte cap on
* JaCoCo UTF strings, and append a series of `SessionInfo` blocks to the
* existing `coverage.ec`. The header is left untouched (JaCoCo wrote it).
*
* Each appended block uses the id format:
* `PATROL_DART_COV:<sequence>:<total>:<base64_chunk>`
* which `patrol bs pull-coverage` reassembles on the host side.
*/
internal object BrowserStackCoverage {
private const val TAG = "BrowserStackCoverage"

// JaCoCo binary format constants (see ExecutionDataWriter).
private const val BLOCK_SESSIONINFO: Byte = 0x10
// DataOutputStream.writeUTF uses a 2-byte length prefix → max 65535 bytes
// for the modified-UTF-8 encoding. Base64 expands ~4/3, and we add a
// ~30-byte id prefix. Chunk raw payload to keep encoded length comfortably
// below the limit: 48_000 raw bytes → ~64_000 b64 chars + prefix.
private const val MAX_CHUNK_BYTES = 48_000

const val ID_PREFIX = "PATROL_DART_COV:"

/**
* Reads any LCOV files in `<filesDir>/patrol_coverage/`, encodes them, and
* appends JaCoCo session blocks to [coverageFile]. Safe to call even when
* no Dart coverage was produced.
*/
fun appendDartCoverage(context: Context, coverageFile: File) {
val t0 = android.os.SystemClock.elapsedRealtime()
val sourceDir = File(context.filesDir, "patrol_coverage")
if (!sourceDir.exists() || !sourceDir.isDirectory) {
Logger.i("$TAG: no patrol_coverage dir at ${sourceDir.absolutePath}, skipping")
return
}

val lcovFiles = sourceDir.listFiles { f -> f.isFile && f.length() > 0 }
?.sortedBy { it.name }
?: emptyList()
if (lcovFiles.isEmpty()) {
Logger.i("$TAG: no Dart coverage files to merge, skipping")
return
}
Logger.i("$TAG: t+${android.os.SystemClock.elapsedRealtime() - t0}ms list ${lcovFiles.size} file(s)")

if (!coverageFile.exists() || coverageFile.length() == 0L) {
// Without a JaCoCo header the file is unreadable. Patrol can't write
// a header itself (would require duplicating JaCoCo's magic + version).
// In practice this only happens when testCoverageEnabled is off — log
// and bail loudly so the misconfig is obvious.
Logger.e(
"$TAG: ${coverageFile.absolutePath} is missing or empty. Is testCoverageEnabled=true on the app under test?",
null,
)
return
}

val merged = StringBuilder()
for (file in lcovFiles) {
merged.append(file.readText())
if (!merged.endsWith("\n")) merged.append('\n')
}
Logger.i("$TAG: t+${android.os.SystemClock.elapsedRealtime() - t0}ms read ${merged.length} chars")

val payload = merged.toString().toByteArray(Charsets.UTF_8)
val chunks = chunkBytes(payload, MAX_CHUNK_BYTES)
Logger.i("$TAG: t+${android.os.SystemClock.elapsedRealtime() - t0}ms chunked ${chunks.size} (${payload.size} bytes)")

FileOutputStream(coverageFile, /* append = */ true).use { fos ->
DataOutputStream(fos).use { out ->
val now = System.currentTimeMillis()
chunks.forEachIndexed { index, chunk ->
val b64 = android.util.Base64.encodeToString(
chunk,
android.util.Base64.NO_WRAP,
)
val id = "$ID_PREFIX${index + 1}:${chunks.size}:$b64"
out.writeByte(BLOCK_SESSIONINFO.toInt())
out.writeUTF(id)
out.writeLong(now)
out.writeLong(now)
}
out.flush()
fos.fd.sync()
}
}
Logger.i("$TAG: t+${android.os.SystemClock.elapsedRealtime() - t0}ms append complete, file now ${coverageFile.length()} bytes")
}

private fun chunkBytes(bytes: ByteArray, chunkSize: Int): List<ByteArray> {
if (bytes.isEmpty()) return emptyList()
val out = ArrayList<ByteArray>((bytes.size + chunkSize - 1) / chunkSize)
var offset = 0
while (offset < bytes.size) {
val end = minOf(offset + chunkSize, bytes.size)
out += bytes.copyOfRange(offset, end)
offset = end
}
return out
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import pl.leancode.patrol.contracts.Contracts;
import pl.leancode.patrol.contracts.PatrolAppServiceClientException;

import java.io.File;
import java.io.FileOutputStream;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
Expand All @@ -34,6 +36,8 @@
public class PatrolJUnitRunner extends AndroidJUnitRunner {
public PatrolAppServiceClient patrolAppServiceClient;
private Map<String, Boolean> dartTestCaseSkipMap = new HashMap<>();
private String coverageFilePath;
private boolean coverageEnabled;

@Override
protected boolean shouldWaitForActivitiesToComplete() {
Expand All @@ -42,15 +46,78 @@ protected boolean shouldWaitForActivitiesToComplete() {

@Override
public void onCreate(Bundle arguments) {
// Capture coverage configuration before AGP wires up its own dump-on-
// finish flow. We then disable AGP's auto-dump because AGP performs it
// during finish() — by which time the process is being torn down and
// we have no reliable window to append Dart-side coverage blocks.
// Instead we drive the dump ourselves from runDartTest() while the
// process is fully alive (see writeMergedCoverage).
coverageFilePath = arguments.getString("coverageFile");
coverageEnabled = "true".equalsIgnoreCase(arguments.getString("coverage"));
if (coverageEnabled) {
Logger.INSTANCE.i(
"BS coverage: taking over JaCoCo dump → " + coverageFilePath
);
arguments.putString("coverage", "false");
}

super.onCreate(arguments);

// This is only true when the ATO requests a list of tests from the app during the initial run.
boolean isInitialRun = Boolean.parseBoolean(arguments.getString("listTestsForOrchestrator"));

Logger.INSTANCE.i("--------------------------------");
Logger.INSTANCE.i("PatrolJUnitRunner.onCreate() " + (isInitialRun ? "(initial run)" : ""));
}

private File resolveCoverageFile() {
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");

}

/**
* Dumps JaCoCo coverage data (collected by the runtime agent) into
* `coverageFilePath`, then appends patrol's Dart-side LCOV blocks. Called
* after each Dart test from runDartTest() — at that point the process is
* fully alive and we have unbounded time, unlike the AGP-driven dump
* inside `finish()` which races against process teardown.
*
* If the JaCoCo runtime classes are absent (e.g. testCoverageEnabled=false
* on the app under test), this method is a silent no-op.
*/
private void writeMergedCoverage() {
if (!coverageEnabled) return;
File covFile = resolveCoverageFile();
if (covFile == null) return;

try {
// org.jacoco.agent.rt.RT is added by AGP when testCoverageEnabled=true.
Class<?> rtClass = Class.forName("org.jacoco.agent.rt.RT");
Object agent = rtClass.getMethod("getAgent").invoke(null);
byte[] data = (byte[]) agent.getClass()
.getMethod("getExecutionData", boolean.class)
.invoke(agent, false);

File parent = covFile.getParentFile();
if (parent != null && !parent.exists()) {
parent.mkdirs();
}
try (FileOutputStream fos = new FileOutputStream(covFile, /* append= */ false)) {
fos.write(data);
fos.flush();
fos.getFD().sync();
}
Logger.INSTANCE.i("BS coverage: wrote " + data.length + " JaCoCo bytes to " + covFile.getAbsolutePath());

BrowserStackCoverage.INSTANCE.appendDartCoverage(getTargetContext(), covFile);
Logger.INSTANCE.i("BS coverage: merged file now " + covFile.length() + " bytes");
} catch (ClassNotFoundException e) {
Logger.INSTANCE.i("BS coverage: JaCoCo runtime absent (testCoverageEnabled=false?), skipping");
} catch (Throwable t) {
Logger.INSTANCE.e("BS coverage: writeMergedCoverage failed " + t.getMessage(), t);
}
}

/**
* <p>
* The native test runner needs to know what tests exist before it can execute them.
Expand Down Expand Up @@ -154,6 +221,10 @@ public RunDartTestResponse runDartTest(String name) {
try {
Logger.INSTANCE.i(TAG + "Requested execution");
RunDartTestResponse response = patrolAppServiceClient.runDartTest(name);
// Dump coverage NOW — Dart side has written its per-test LCOV in
// tearDown(), and the instrumentation process is alive. Doing this
// here instead of in finish() avoids racing the process teardown.
writeMergedCoverage();
if (response.getResult() == Contracts.RunDartTestResponseResult.failure) {
throw new AssertionError("Dart test failed: " + name + "\n" + response.getDetails());
}
Expand Down
7 changes: 7 additions & 0 deletions packages/patrol/lib/src/binding.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patrol/patrol.dart';
import 'package:patrol/src/bs_coverage.dart';
import 'package:patrol/src/devtools_service_extensions/devtools_service_extensions.dart';
import 'package:patrol/src/global_state.dart' as global_state;
import 'package:patrol/src/platform/current.dart' as current_platform;
Expand Down Expand Up @@ -103,6 +104,12 @@ class PatrolBinding extends LiveTestWidgetsFlutterBinding {
'tearDown(): test "$testName" in group "$_currentDartTest", passed: $passed',
);

await BrowserStackCoverage.recordTestCompleted(
testName: testName,
testFilePath: _currentDartTest!,
passed: passed,
);

await patrolAppService.markDartTestAsCompleted(
dartFileName: _currentDartTest!,
passed: passed,
Expand Down
Loading
Loading