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
33 changes: 33 additions & 0 deletions .github/workflows/dependency-submission.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Dependency Submission

# Submits the Gradle dependency graph to GitHub's Dependency Graph API.
#
# This is the SDK-aware replacement for GitHub's built-in "Automatic Dependency
# Submission" feature, which fails on this repository (the `submit-gradle`
# check) because it runs `./gradlew help` WITHOUT installing the Android SDK —
# the `com.android.*` Gradle plugins cannot configure the project, so it errors
# with "SDK location not found". This workflow installs the SDK first.
#
# To stop the failing built-in check, a repo admin must disable it under
# Settings -> Code security -> Automatic dependency submission. This workflow
# then keeps the dependency graph up to date.
on:
push:
branches: [main]
workflow_dispatch:

permissions:
contents: write

jobs:
dependency-submission:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'
- uses: android-actions/setup-android@v3
- name: Generate and submit dependency graph
uses: gradle/actions/dependency-submission@v4
47 changes: 47 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
name: Unit tests & coverage gate

on:
pull_request:
push:
branches:
- main
- master

jobs:
unit-tests:
name: ":espresso unit tests + JaCoCo coverage gate"
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4

- name: Set up JDK 17
uses: actions/setup-java@v4
with:
distribution: temurin
java-version: '17'

- name: Set up Android SDK
uses: android-actions/setup-android@v3

- name: Make gradlew executable
run: chmod +x ./gradlew

- name: Run :espresso unit tests and coverage verification
run: ./gradlew :espresso:testDebugUnitTest :espresso:jacocoTestCoverageVerification --stacktrace

- name: Upload JaCoCo HTML report
if: always()
uses: actions/upload-artifact@v4
with:
name: jacoco-report
path: espresso/build/reports/jacoco/jacocoTestReport
if-no-files-found: warn

- name: Upload unit test results
if: always()
uses: actions/upload-artifact@v4
with:
name: unit-test-results
path: espresso/build/reports/tests/testDebugUnitTest
if-no-files-found: warn

Check warning

Code scanning / CodeQL

Workflow does not contain permissions Medium test

Actions job or workflow does not limit the permissions of the GITHUB_TOKEN. Consider setting an explicit permissions block, using the following as a minimal starting point: {contents: read}
Comment on lines +12 to +47
8 changes: 7 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@
plugins {
id 'com.android.application' version '7.4.1' apply false
id 'com.android.library' version '7.4.1' apply false
id("io.github.gradle-nexus.publish-plugin") version "1.1.0"
// 1.3.0 is the first release compatible with Gradle 8+ (it dropped the
// org.gradle.util.ConfigureUtil usage that 1.1.0 relied on, which is removed
// in Gradle 8). The repo wrapper is Gradle 7.5 and 1.3.0 still supports 6.2+,
// so the normal build is unaffected; this lets the dependency-graph submission
// (which runs a newer Gradle) evaluate scripts/publish-root.gradle instead of
// failing with NoClassDefFoundError: org/gradle/util/ConfigureUtil.
id("io.github.gradle-nexus.publish-plugin") version "1.3.0"
}
apply from: "${rootDir}/scripts/publish-root.gradle"
109 changes: 109 additions & 0 deletions espresso/build.gradle
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
id 'com.android.library'
id 'jacoco'
}

android {
Expand All @@ -20,16 +21,51 @@ android {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
debug {
// Required for JaCoCo to instrument the unit-test classpath.
testCoverageEnabled true
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8
targetCompatibility JavaVersion.VERSION_1_8
}

testOptions {
unitTests {
// Load src/main/resources (deviceInfo.json, devices.csv) and android
// resources so Robolectric-backed unit tests behave like on-device.
includeAndroidResources = true
// Let un-mocked android.* calls return defaults instead of throwing
// "Method ... not mocked" for the few framework calls we do not drive
// through Robolectric.
returnDefaultValues = true

// Required so JaCoCo records coverage for classes loaded through
// Robolectric's sandbox classloader (otherwise Robolectric-driven
// classes report 0%). The jdk.internal.* exclude is needed on
// JDK 11+ so the agent does not try to instrument JDK internals.
all {
jacoco {
includeNoLocationClasses = true
excludes = ['jdk.internal.*']
}
}
}
}
}

dependencies {
implementation 'androidx.test:runner:1.5.2'
implementation 'androidx.test:core:1.5.0'

testImplementation 'junit:junit:4.13.2'
testImplementation 'org.json:json:20231013'
testImplementation 'org.robolectric:robolectric:4.10.3'
testImplementation 'org.mockito:mockito-core:5.2.0'
testImplementation 'org.mockito:mockito-inline:5.2.0'
testImplementation 'androidx.test:core:1.5.0'
testImplementation 'androidx.test.ext:junit:1.1.5'
}

ext {
Expand All @@ -38,4 +74,77 @@ ext {
PUBLISH_ARTIFACT_ID = 'espresso-java'
}

jacoco {
toolVersion = "0.8.10"
}

// Source/exec inputs shared by the report and the verification tasks.
ext.jacocoExecData = "${buildDir}/outputs/unit_test_code_coverage/debugUnitTest/testDebugUnitTest.exec"
ext.jacocoClassDirs = {
// Instrumented main classes for the debug variant. No production exclusions:
// every hand-written class under src/main/java is counted toward coverage.
// Only the AGP-generated BuildConfig/R stubs (which are NOT in src/main/java)
// are filtered out so the ratio reflects real source.
fileTree(dir: "${buildDir}/intermediates/javac/debug/classes",
includes: ["**/*.class"],
excludes: ["**/BuildConfig.class", "**/R.class", "**/R\$*.class"])
}
ext.jacocoSourceDirs = files("${projectDir}/src/main/java")

tasks.register('jacocoTestReport', JacocoReport) {
dependsOn 'testDebugUnitTest'
group = 'verification'
description = 'Generates JaCoCo line/branch coverage report for :espresso unit tests.'

reports {
xml.required = true
html.required = true
}

sourceDirectories.setFrom(jacocoSourceDirs)
classDirectories.setFrom(files(jacocoClassDirs()))
executionData.setFrom(files(jacocoExecData))
}

tasks.register('jacocoTestCoverageVerification', JacocoCoverageVerification) {
dependsOn 'testDebugUnitTest'
group = 'verification'
description = 'Fails the build if :espresso line coverage drops below the floor.'

sourceDirectories.setFrom(jacocoSourceDirs)
classDirectories.setFrom(files(jacocoClassDirs()))
executionData.setFrom(files(jacocoExecData))

violationRules {
rule {
limit {
counter = 'LINE'
value = 'COVEREDRATIO'
// Full line coverage: 277/277 lines = 100%.
//
// The previously-uncovered remote-CSV fallback in
// MetadataHelper.deviceNameFromCSV (the
// https://storage.googleapis.com/.../supported_devices.csv
// fetch plus its catch(IOException)/return-null tail) is now
// exercised by real tests. No JaCoCo excludes, no @Generated, no
// pragmas -- every line is covered by a behavior test. The remote
// fetch was made reachable by minimal, behavior-preserving testability
// seams in MetadataHelper (localLookup / openRemoteCsvReader /
// remoteCsvUrl), letting MetadataHelperTest force a local miss and
// drive the fallback against a loopback StubHttpServer (success path)
// or an injected IOException (catch/return-null path).
//
// The minimum is the hard 1.00 gate: any regression below full
// line coverage fails the build.
minimum = 1.00
}
}
}
}

// Make the report follow the verification so a single gate task produces both.
tasks.named('jacocoTestCoverageVerification').configure {
finalizedBy 'jacocoTestReport'
}

apply from: "${rootProject.projectDir}/scripts/publish-module.gradle"
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,25 @@ public static String deviceNameFromCSV() {
return deviceNameFromCSV(Build.MODEL);
}
public static String deviceNameFromCSV(String model) {
return new MetadataHelper().resolveDeviceNameFromCSV(model);
}

/**
* Instance form of {@link #deviceNameFromCSV(String)}. The static entry
* points delegate here through a default {@code MetadataHelper} instance so
* production behavior is byte-for-byte identical: the local bundled CSV is
* tried first and, only when it yields no match, the remote Google Play
* device CSV is consulted. The reader creation for each source is funneled
* through {@link #openLocalCsvReader()} / {@link #openRemoteCsvReader()} so
* tests can drive the otherwise network-only fallback and its IOException
* handling without changing any of this control flow.
*/
protected String resolveDeviceNameFromCSV(String model) {
try
{
InputStream inputStream = Metadata.class.getResourceAsStream("/devices.csv");
BufferedReader bufferedReader = new BufferedReader(new InputStreamReader(inputStream));
String device = parseBufferReader(bufferedReader, model);
String device = localLookup(model);
if (device == null) {
URL url = new URL("https://storage.googleapis.com/play_public/supported_devices.csv");
BufferedReader bufferedReaderNew = new BufferedReader(new InputStreamReader(url.openStream()));
BufferedReader bufferedReaderNew = openRemoteCsvReader();
device = parseBufferReader(bufferedReaderNew, model);
}
return device;
Expand All @@ -38,6 +49,49 @@ public static String deviceNameFromCSV(String model) {
return null;
}

/**
* Resolves {@code model} against the bundled {@code /devices.csv}. Returns
* exactly what {@code parseBufferReader(openLocalCsvReader(), model)}
* returned inline before extraction, so production behavior is unchanged.
* Exposed as an overridable seam so a test can force a local miss (return
* {@code null}) and thereby exercise the remote-CSV fallback branch.
*/
protected String localLookup(String model) {
BufferedReader bufferedReader = openLocalCsvReader();
return parseBufferReader(bufferedReader, model);
}

/**
* Opens a reader over the bundled {@code /devices.csv} resource. Extracted
* as an overridable seam; the body is exactly what previously sat inline in
* {@code deviceNameFromCSV}.
*/
protected BufferedReader openLocalCsvReader() {
InputStream inputStream = Metadata.class.getResourceAsStream("/devices.csv");
return new BufferedReader(new InputStreamReader(inputStream));
}

/**
* Opens a reader over the remote Google Play supported-devices CSV. Extracted
* as an overridable seam; the body is exactly what previously sat inline in
* the {@code device == null} fallback of {@code deviceNameFromCSV}. The URL
* itself comes from {@link #remoteCsvUrl()} so a test can point the very same
* open/stream logic at a loopback stub instead of the real network endpoint.
*/
protected BufferedReader openRemoteCsvReader() throws IOException {
URL url = new URL(remoteCsvUrl());
return new BufferedReader(new InputStreamReader(url.openStream()));
}

/**
* The remote supported-devices CSV endpoint. Unchanged production constant;
* isolated as an overridable seam purely so tests can redirect
* {@link #openRemoteCsvReader()} at a local stub server.
*/
protected String remoteCsvUrl() {
return "https://storage.googleapis.com/play_public/supported_devices.csv";
}

public static String sanitizedString(String string) {
return string.replaceAll("[^ a-zA-Z0-9()._+-]", "").trim();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,10 +35,22 @@ public JSONObject getTag() throws JSONException {
return tag;
}

/**
* Acquires the screenshot bitmap. Extracted as an overridable seam so the
* bitmap source can be substituted in unit tests without an emulator. Runtime
* behavior is unchanged: production callers still go through
* {@code Screenshot.capture()} exactly as before.
*
* @return the captured screen bitmap
*/
protected Bitmap captureBitmap() {
return Screenshot.capture().getBitmap();
}

public List<Tile> captureTiles(Boolean fullScreen) {
Integer statusBar = metadata.statBarHeight();
Integer navBar = metadata.navBarHeight();
Bitmap bitmap = Screenshot.capture().getBitmap();
Bitmap bitmap = captureBitmap();
final ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream);
String content = Base64.encodeToString(outputStream.toByteArray(), Base64.DEFAULT);
Expand Down
Loading
Loading