diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS
index cce00af1d0d..875bcba4ba9 100644
--- a/.github/CODEOWNERS
+++ b/.github/CODEOWNERS
@@ -111,6 +111,7 @@
/dd-java-agent/instrumentation/jacoco-0.8.9/ @DataDog/ci-app-libraries
/dd-java-agent/instrumentation/junit @DataDog/ci-app-libraries
/dd-java-agent/instrumentation/karate-1.0/ @DataDog/ci-app-libraries
+/dd-java-agent/instrumentation/robolectric/ @DataDog/ci-app-libraries
/dd-java-agent/instrumentation/scalatest-3.0.8/ @DataDog/ci-app-libraries
/dd-java-agent/instrumentation/selenium-3.13/ @DataDog/ci-app-libraries
/dd-java-agent/instrumentation/testng/ @DataDog/ci-app-libraries
diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/TestImpl.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/TestImpl.java
index 5266de922af..b91c9756cd3 100644
--- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/TestImpl.java
+++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/domain/TestImpl.java
@@ -22,6 +22,7 @@
import datadog.trace.api.civisibility.telemetry.tag.EventType;
import datadog.trace.api.civisibility.telemetry.tag.FailedTestReplayEnabled;
import datadog.trace.api.civisibility.telemetry.tag.HasFailedAllRetries;
+import datadog.trace.api.civisibility.telemetry.tag.IsAndroid;
import datadog.trace.api.civisibility.telemetry.tag.IsAttemptToFix;
import datadog.trace.api.civisibility.telemetry.tag.IsDisabled;
import datadog.trace.api.civisibility.telemetry.tag.IsModified;
@@ -323,7 +324,8 @@ public void end(@Nullable Long endTime) {
span.getTag(Tags.TEST_IS_RUM_ACTIVE) != null ? IsRum.TRUE : null,
CIConstants.SELENIUM_BROWSER_DRIVER.equals(span.getTag(Tags.TEST_BROWSER_DRIVER))
? BrowserDriver.SELENIUM
- : null);
+ : null,
+ span.getTag(Tags.TEST_ANDROID_API_LEVEL) != null ? IsAndroid.TRUE : null);
}
/**
diff --git a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/events/TestEventsHandlerImpl.java b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/events/TestEventsHandlerImpl.java
index 26b083f0c17..2f0eec1c39a 100644
--- a/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/events/TestEventsHandlerImpl.java
+++ b/dd-java-agent/agent-ci-visibility/src/main/java/datadog/trace/civisibility/events/TestEventsHandlerImpl.java
@@ -5,6 +5,8 @@
import datadog.trace.api.civisibility.CIConstants;
import datadog.trace.api.civisibility.DDTest;
import datadog.trace.api.civisibility.DDTestSuite;
+import datadog.trace.api.civisibility.android.AndroidTestContext;
+import datadog.trace.api.civisibility.android.AndroidTestInfo;
import datadog.trace.api.civisibility.config.TestIdentifier;
import datadog.trace.api.civisibility.config.TestSourceData;
import datadog.trace.api.civisibility.events.TestEventsHandler;
@@ -220,6 +222,24 @@ public void onTestStart(
inProgressTests.put(descriptor, test);
}
+ /** Applies emulated Android SDK metadata captured by the Robolectric instrumentation. */
+ private void populateAndroidTags(TestImpl test) {
+ AndroidTestInfo androidInfo = AndroidTestContext.getAndClear();
+ if (androidInfo == null) {
+ return;
+ }
+ test.setTag(Tags.TEST_ANDROID_API_LEVEL, androidInfo.getApiLevel());
+ if (androidInfo.getRelease() != null) {
+ test.setTag(Tags.TEST_ANDROID_RELEASE, androidInfo.getRelease());
+ }
+ if (androidInfo.getCodename() != null) {
+ test.setTag(Tags.TEST_ANDROID_CODENAME, androidInfo.getCodename());
+ }
+ if (androidInfo.getRobolectricVersion() != null) {
+ test.setTag(Tags.TEST_ANDROID_ROBOLECTRIC_VERSION, androidInfo.getRobolectricVersion());
+ }
+ }
+
@Override
public void onTestSkip(TestKey descriptor, @Nullable String reason) {
TestImpl test = inProgressTests.get(descriptor);
@@ -284,6 +304,8 @@ public void onTestFinish(
test.setTag(Tags.TEST_FINAL_STATUS, testStatus);
}
+ populateAndroidTags(test);
+
test.end(endTime);
}
diff --git a/dd-java-agent/instrumentation/robolectric/robolectric-4.11/build.gradle b/dd-java-agent/instrumentation/robolectric/robolectric-4.11/build.gradle
new file mode 100644
index 00000000000..a61b36a1397
--- /dev/null
+++ b/dd-java-agent/instrumentation/robolectric/robolectric-4.11/build.gradle
@@ -0,0 +1,21 @@
+muzzle {
+ pass {
+ group = 'org.robolectric'
+ module = 'robolectric'
+ versions = '[4.11,)'
+ }
+}
+
+apply from: "$rootDir/gradle/java.gradle"
+
+dependencies {
+ // Instrumentation is validated through a scenario in `GradleDaemonSmokeTest`.
+ // Exclude androidx.test:monitor: it is an Android archive (.aar) published only to Google's Maven
+ // repository, is not referenced by the advice/helper.
+ compileOnly(group: 'org.robolectric', name: 'robolectric', version: '4.16.1') {
+ exclude group: 'androidx.test', module: 'monitor'
+ }
+ // RobolectricTestRunner extends JUnit's BlockJUnit4ClassRunner; JUnit must be on the compile
+ // classpath so its supertypes resolve (both javac and forbiddenApis walk the class hierarchy).
+ compileOnly group: 'junit', name: 'junit', version: '4.13.2'
+}
diff --git a/dd-java-agent/instrumentation/robolectric/robolectric-4.11/gradle.lockfile b/dd-java-agent/instrumentation/robolectric/robolectric-4.11/gradle.lockfile
new file mode 100644
index 00000000000..8fdcd30dcc0
--- /dev/null
+++ b/dd-java-agent/instrumentation/robolectric/robolectric-4.11/gradle.lockfile
@@ -0,0 +1,150 @@
+# This is a Gradle generated file for dependency locking.
+# Manual edits can break the build and are not advised.
+# This file is expected to be part of source control.
+# To regenerate this file, run: ./gradlew :dd-java-agent:instrumentation:robolectric:robolectric-4.11:dependencies --write-locks
+cafe.cryptography:curve25519-elisabeth:0.1.0=testRuntimeClasspath
+cafe.cryptography:ed25519-elisabeth:0.1.0=testRuntimeClasspath
+ch.qos.logback:logback-classic:1.2.13=testCompileClasspath,testRuntimeClasspath
+ch.qos.logback:logback-core:1.2.13=testCompileClasspath,testRuntimeClasspath
+com.almworks.sqlite4java:sqlite4java:1.0.392=compileClasspath
+com.blogspot.mydailyjava:weak-lock-free:0.17=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.datadoghq.okhttp3:okhttp:3.12.15=testCompileClasspath,testRuntimeClasspath
+com.datadoghq.okio:okio:1.17.6=testCompileClasspath,testRuntimeClasspath
+com.datadoghq:dd-instrument-java:0.0.4=buildTimeInstrumentationPlugin,compileClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.datadoghq:dd-javac-plugin-client:0.2.2=buildTimeInstrumentationPlugin,compileClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+com.datadoghq:java-dogstatsd-client:4.4.5=testRuntimeClasspath
+com.datadoghq:sketches-java:0.8.3=testRuntimeClasspath
+com.github.javaparser:javaparser-core:3.25.6=codenarc
+com.github.jnr:jffi:1.3.15=testRuntimeClasspath
+com.github.jnr:jnr-a64asm:1.0.0=testRuntimeClasspath
+com.github.jnr:jnr-constants:0.10.4=testRuntimeClasspath
+com.github.jnr:jnr-enxio:0.32.20=testRuntimeClasspath
+com.github.jnr:jnr-ffi:2.2.19=testRuntimeClasspath
+com.github.jnr:jnr-posix:3.1.22=testRuntimeClasspath
+com.github.jnr:jnr-unixsocket:0.38.25=testRuntimeClasspath
+com.github.jnr:jnr-x86asm:1.0.2=testRuntimeClasspath
+com.github.spotbugs:spotbugs-annotations:4.9.8=compileClasspath,spotbugs
+com.github.spotbugs:spotbugs:4.9.8=spotbugs
+com.github.stephenc.jcip:jcip-annotations:1.0-1=spotbugs
+com.google.auto.service:auto-service-annotations:1.1.1=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath
+com.google.auto.service:auto-service:1.1.1=annotationProcessor,testAnnotationProcessor
+com.google.auto.value:auto-value-annotations:1.11.0=compileClasspath
+com.google.auto:auto-common:1.2.1=annotationProcessor,testAnnotationProcessor
+com.google.code.findbugs:jsr305:3.0.2=annotationProcessor,compileClasspath,spotbugs,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.code.gson:gson:2.13.2=spotbugs
+com.google.errorprone:error_prone_annotations:2.18.0=annotationProcessor,testAnnotationProcessor
+com.google.errorprone:error_prone_annotations:2.36.0=compileClasspath
+com.google.errorprone:error_prone_annotations:2.41.0=spotbugs
+com.google.errorprone:error_prone_annotations:2.47.0=testCompileClasspath,testRuntimeClasspath
+com.google.guava:failureaccess:1.0.1=annotationProcessor,testAnnotationProcessor
+com.google.guava:failureaccess:1.0.3=compileClasspath,testCompileClasspath,testRuntimeClasspath
+com.google.guava:guava:32.0.1-jre=annotationProcessor,testAnnotationProcessor
+com.google.guava:guava:33.4.8-jre=compileClasspath
+com.google.guava:guava:33.6.0-jre=testCompileClasspath,testRuntimeClasspath
+com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava=annotationProcessor,compileClasspath,testAnnotationProcessor,testCompileClasspath,testRuntimeClasspath
+com.google.j2objc:j2objc-annotations:2.8=annotationProcessor,testAnnotationProcessor
+com.google.j2objc:j2objc-annotations:3.0.0=compileClasspath
+com.google.j2objc:j2objc-annotations:3.1=testCompileClasspath,testRuntimeClasspath
+com.google.re2j:re2j:1.8=testRuntimeClasspath
+com.ibm.icu:icu4j:77.1=compileClasspath
+com.squareup.moshi:moshi:1.11.0=testCompileClasspath,testRuntimeClasspath
+com.squareup.okhttp3:logging-interceptor:3.12.12=testCompileClasspath,testRuntimeClasspath
+com.squareup.okhttp3:okhttp:3.12.12=testCompileClasspath,testRuntimeClasspath
+com.squareup.okio:okio:1.17.5=testCompileClasspath,testRuntimeClasspath
+com.thoughtworks.qdox:qdox:1.12.1=codenarc
+commons-fileupload:commons-fileupload:1.5=testCompileClasspath,testRuntimeClasspath
+commons-io:commons-io:2.11.0=testCompileClasspath,testRuntimeClasspath
+commons-io:commons-io:2.20.0=spotbugs
+de.thetaphi:forbiddenapis:3.10=compileClasspath,testCompileClasspath,testRuntimeClasspath
+io.leangen.geantyref:geantyref:1.3.16=testRuntimeClasspath
+io.sqreen:libsqreen:17.3.0=testRuntimeClasspath
+javax.annotation:javax.annotation-api:1.3.2=compileClasspath
+javax.inject:javax.inject:1=compileClasspath
+javax.servlet:javax.servlet-api:3.1.0=testCompileClasspath,testRuntimeClasspath
+jaxen:jaxen:2.0.0=spotbugs
+junit:junit:4.13.2=compileClasspath,testRuntimeClasspath
+net.bytebuddy:byte-buddy-agent:1.18.10=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+net.bytebuddy:byte-buddy:1.18.10=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+net.java.dev.jna:jna-platform:5.8.0=testRuntimeClasspath
+net.java.dev.jna:jna:5.8.0=testRuntimeClasspath
+net.sf.saxon:Saxon-HE:12.9=spotbugs
+org.apache.ant:ant-antlr:1.10.14=codenarc
+org.apache.ant:ant-junit:1.10.14=codenarc
+org.apache.bcel:bcel:6.11.0=spotbugs
+org.apache.commons:commons-lang3:3.19.0=spotbugs
+org.apache.commons:commons-text:1.14.0=spotbugs
+org.apache.logging.log4j:log4j-api:2.25.2=spotbugs
+org.apache.logging.log4j:log4j-core:2.25.2=spotbugs
+org.apiguardian:apiguardian-api:1.1.2=testCompileClasspath,testRuntimeClasspath
+org.bouncycastle:bcprov-jdk18on:1.81=compileClasspath
+org.checkerframework:checker-qual:3.33.0=annotationProcessor,testAnnotationProcessor
+org.codehaus.groovy:groovy-ant:3.0.23=codenarc
+org.codehaus.groovy:groovy-docgenerator:3.0.23=codenarc
+org.codehaus.groovy:groovy-groovydoc:3.0.23=codenarc
+org.codehaus.groovy:groovy-json:3.0.23=codenarc
+org.codehaus.groovy:groovy-json:3.0.25=testCompileClasspath,testRuntimeClasspath
+org.codehaus.groovy:groovy-templates:3.0.23=codenarc
+org.codehaus.groovy:groovy-xml:3.0.23=codenarc
+org.codehaus.groovy:groovy:3.0.23=codenarc
+org.codehaus.groovy:groovy:3.0.25=testCompileClasspath,testRuntimeClasspath
+org.codenarc:CodeNarc:3.7.0=codenarc
+org.dom4j:dom4j:2.2.0=spotbugs
+org.gmetrics:GMetrics:2.1.0=codenarc
+org.hamcrest:hamcrest-core:1.3=compileClasspath,testRuntimeClasspath
+org.hamcrest:hamcrest:3.0=testCompileClasspath,testRuntimeClasspath
+org.jctools:jctools-core-jdk11:4.0.6=testRuntimeClasspath
+org.jctools:jctools-core:4.0.6=testRuntimeClasspath
+org.jspecify:jspecify:1.0.0=compileClasspath,testCompileClasspath,testRuntimeClasspath
+org.junit.jupiter:junit-jupiter-api:5.14.1=testCompileClasspath,testRuntimeClasspath
+org.junit.jupiter:junit-jupiter-engine:5.14.1=testRuntimeClasspath
+org.junit.jupiter:junit-jupiter-params:5.14.1=testCompileClasspath,testRuntimeClasspath
+org.junit.jupiter:junit-jupiter:5.14.1=testCompileClasspath,testRuntimeClasspath
+org.junit.platform:junit-platform-commons:1.14.1=testCompileClasspath,testRuntimeClasspath
+org.junit.platform:junit-platform-engine:1.14.1=testCompileClasspath,testRuntimeClasspath
+org.junit.platform:junit-platform-launcher:1.14.1=testRuntimeClasspath
+org.junit.platform:junit-platform-runner:1.14.1=testRuntimeClasspath
+org.junit.platform:junit-platform-suite-api:1.14.1=testRuntimeClasspath
+org.junit.platform:junit-platform-suite-commons:1.14.1=testRuntimeClasspath
+org.junit:junit-bom:5.14.0=spotbugs
+org.junit:junit-bom:5.14.1=testCompileClasspath,testRuntimeClasspath
+org.mockito:mockito-core:4.4.0=testRuntimeClasspath
+org.objenesis:objenesis:3.3=testCompileClasspath,testRuntimeClasspath
+org.opentest4j:opentest4j:1.3.0=testCompileClasspath,testRuntimeClasspath
+org.ow2.asm:asm-analysis:9.7.1=testRuntimeClasspath
+org.ow2.asm:asm-analysis:9.9=spotbugs
+org.ow2.asm:asm-commons:9.8=compileClasspath
+org.ow2.asm:asm-commons:9.9=spotbugs
+org.ow2.asm:asm-commons:9.9.1=testRuntimeClasspath
+org.ow2.asm:asm-tree:9.8=compileClasspath
+org.ow2.asm:asm-tree:9.9=spotbugs
+org.ow2.asm:asm-tree:9.9.1=testRuntimeClasspath
+org.ow2.asm:asm-util:9.7.1=testRuntimeClasspath
+org.ow2.asm:asm-util:9.9=spotbugs
+org.ow2.asm:asm:9.9=spotbugs
+org.ow2.asm:asm:9.9.1=buildTimeInstrumentationPlugin,compileClasspath,muzzleTooling,runtimeClasspath,testCompileClasspath,testRuntimeClasspath
+org.robolectric:annotations:4.16.1=compileClasspath
+org.robolectric:junit:4.16.1=compileClasspath
+org.robolectric:nativeruntime:4.16.1=compileClasspath
+org.robolectric:pluginapi:4.16.1=compileClasspath
+org.robolectric:plugins-maven-dependency-resolver:4.16.1=compileClasspath
+org.robolectric:resources:4.16.1=compileClasspath
+org.robolectric:robolectric:4.16.1=compileClasspath
+org.robolectric:sandbox:4.16.1=compileClasspath
+org.robolectric:shadowapi:4.16.1=compileClasspath
+org.robolectric:shadows-framework:4.16.1=compileClasspath
+org.robolectric:utils-reflector:4.16.1=compileClasspath
+org.robolectric:utils:4.16.1=compileClasspath
+org.slf4j:jcl-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath
+org.slf4j:jul-to-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath
+org.slf4j:log4j-over-slf4j:1.7.30=testCompileClasspath,testRuntimeClasspath
+org.slf4j:slf4j-api:1.7.30=buildTimeInstrumentationPlugin,compileClasspath,muzzleBootstrap,muzzleTooling,runtimeClasspath
+org.slf4j:slf4j-api:1.7.32=testCompileClasspath,testRuntimeClasspath
+org.slf4j:slf4j-api:2.0.17=spotbugs,spotbugsSlf4j
+org.slf4j:slf4j-simple:2.0.17=spotbugsSlf4j
+org.snakeyaml:snakeyaml-engine:2.9=buildTimeInstrumentationPlugin,muzzleTooling,runtimeClasspath,testRuntimeClasspath
+org.spockframework:spock-bom:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath
+org.spockframework:spock-core:2.4-groovy-3.0=testCompileClasspath,testRuntimeClasspath
+org.tabletest:tabletest-junit:1.2.1=testCompileClasspath,testRuntimeClasspath
+org.tabletest:tabletest-parser:1.2.0=testCompileClasspath,testRuntimeClasspath
+org.xmlresolver:xmlresolver:5.3.3=spotbugs
+empty=spotbugsPlugins
diff --git a/dd-java-agent/instrumentation/robolectric/robolectric-4.11/src/main/java/datadog/trace/instrumentation/robolectric/RobolectricInstrumentation.java b/dd-java-agent/instrumentation/robolectric/robolectric-4.11/src/main/java/datadog/trace/instrumentation/robolectric/RobolectricInstrumentation.java
new file mode 100644
index 00000000000..984ee7bf45c
--- /dev/null
+++ b/dd-java-agent/instrumentation/robolectric/robolectric-4.11/src/main/java/datadog/trace/instrumentation/robolectric/RobolectricInstrumentation.java
@@ -0,0 +1,63 @@
+package datadog.trace.instrumentation.robolectric;
+
+import static datadog.trace.agent.tooling.bytebuddy.matcher.HierarchyMatchers.implementsInterface;
+import static datadog.trace.agent.tooling.bytebuddy.matcher.NameMatchers.named;
+
+import com.google.auto.service.AutoService;
+import datadog.trace.agent.tooling.Instrumenter;
+import datadog.trace.agent.tooling.InstrumenterModule;
+import net.bytebuddy.asm.Advice;
+import net.bytebuddy.description.type.TypeDescription;
+import net.bytebuddy.matcher.ElementMatcher;
+
+/**
+ * Captures the emulated Android SDK for tests running under Robolectric.
+ *
+ *
Robolectric establishes the emulated SDK in {@code TestEnvironment#setUpApplicationState},
+ * which runs on the per-SDK sandbox "main" thread right before the test body (the SDK is not yet
+ * set when the JUnit test-start event fires, and is torn down before the finish event). This
+ * instrumentation reads the SDK there and stashes it in {@link
+ * datadog.trace.api.civisibility.android.AndroidTestContext}; the CI Visibility core drains it and
+ * attaches the {@code test.android.*} tags to the test span (see {@code
+ * TestEventsHandlerImpl#onTestFinish}).
+ *
+ *
Robolectric tests are JUnit tests, so their spans are still produced by the JUnit
+ * instrumentation — this only enriches them with the Android metadata, and only loads when
+ * Robolectric is on the classpath.
+ */
+@AutoService(InstrumenterModule.class)
+public class RobolectricInstrumentation extends InstrumenterModule.CiVisibility
+ implements Instrumenter.ForTypeHierarchy, Instrumenter.HasMethodAdvice {
+
+ public RobolectricInstrumentation() {
+ super("ci-visibility", "robolectric");
+ }
+
+ @Override
+ public String hierarchyMarkerType() {
+ return "org.robolectric.internal.TestEnvironment";
+ }
+
+ @Override
+ public ElementMatcher hierarchyMatcher() {
+ return implementsInterface(named(hierarchyMarkerType()));
+ }
+
+ @Override
+ public String[] helperClassNames() {
+ return new String[] {packageName + ".RobolectricTestExtractor"};
+ }
+
+ @Override
+ public void methodAdvice(MethodTransformer transformer) {
+ transformer.applyAdvice(
+ named("setUpApplicationState"), getClass().getName() + "$SetUpApplicationStateAdvice");
+ }
+
+ public static class SetUpApplicationStateAdvice {
+ @Advice.OnMethodExit(suppress = Throwable.class)
+ public static void onExit() {
+ RobolectricTestExtractor.capture();
+ }
+ }
+}
diff --git a/dd-java-agent/instrumentation/robolectric/robolectric-4.11/src/main/java/datadog/trace/instrumentation/robolectric/RobolectricTestExtractor.java b/dd-java-agent/instrumentation/robolectric/robolectric-4.11/src/main/java/datadog/trace/instrumentation/robolectric/RobolectricTestExtractor.java
new file mode 100644
index 00000000000..be42915300d
--- /dev/null
+++ b/dd-java-agent/instrumentation/robolectric/robolectric-4.11/src/main/java/datadog/trace/instrumentation/robolectric/RobolectricTestExtractor.java
@@ -0,0 +1,58 @@
+package datadog.trace.instrumentation.robolectric;
+
+import datadog.trace.api.civisibility.android.AndroidTestContext;
+import datadog.trace.api.civisibility.android.AndroidTestInfo;
+import java.io.File;
+import java.net.URL;
+import java.security.CodeSource;
+import java.security.ProtectionDomain;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.versioning.AndroidVersions;
+
+/**
+ * Reads the emulated Android SDK and Robolectric version (once the sandbox has established the SDK)
+ * and hands them to the CI Visibility core via {@link AndroidTestContext}.
+ */
+public final class RobolectricTestExtractor {
+
+ /** Matches the version in a {@code robolectric-.jar} file name. */
+ private static final Pattern ROBOLECTRIC_JAR = Pattern.compile("^robolectric-(.+)\\.jar$");
+
+ private RobolectricTestExtractor() {}
+
+ public static void capture() {
+ int apiLevel = RuntimeEnvironment.getApiLevel();
+ if (apiLevel <= 0) {
+ return;
+ }
+ String release = null;
+ String codename = null;
+ AndroidVersions.AndroidRelease androidRelease = AndroidVersions.getReleaseForSdkInt(apiLevel);
+ if (androidRelease != null) {
+ release = androidRelease.getVersion();
+ codename = androidRelease.getShortCode();
+ }
+ AndroidTestContext.set(new AndroidTestInfo(apiLevel, release, codename, robolectricVersion()));
+ }
+
+ private static String robolectricVersion() {
+ try {
+ // RuntimeEnvironment is re-loaded by the sandbox classloader with no CodeSource, but the
+ // runner runs outside the sandbox (it creates it), so it is delegated to the application
+ // classloader and its CodeSource points at the real robolectric-.jar.
+ ProtectionDomain protectionDomain = RobolectricTestRunner.class.getProtectionDomain();
+ CodeSource codeSource = protectionDomain != null ? protectionDomain.getCodeSource() : null;
+ URL location = codeSource != null ? codeSource.getLocation() : null;
+ if (location == null) {
+ return null;
+ }
+ Matcher matcher = ROBOLECTRIC_JAR.matcher(new File(location.getPath()).getName());
+ return matcher.matches() ? matcher.group(1) : null;
+ } catch (Throwable t) {
+ return null;
+ }
+ }
+}
diff --git a/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDaemonSmokeTest.java b/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDaemonSmokeTest.java
index 5461ec2056b..635066e21a8 100644
--- a/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDaemonSmokeTest.java
+++ b/dd-smoke-tests/gradle/src/test/java/datadog/smoketest/GradleDaemonSmokeTest.java
@@ -127,6 +127,35 @@ void testNew(
expectedCoverages);
}
+ @TableTest({
+ "scenario | gradleVersion | projectName | expectedTraces",
+ "robolectric-latest | latest | test-succeed-robolectric | 6 "
+ })
+ @ParameterizedTest
+ void testRobolectric(String gradleVersion, String projectName, int expectedTraces)
+ throws IOException {
+ // Robolectric 4.16 requires JDK 17+ for its sandbox.
+ Assumptions.assumeTrue(
+ JavaVirtualMachine.isJavaVersionAtLeast(17), "Robolectric requires JDK 17 or higher");
+
+ gradleVersion = resolveVersion(gradleVersion);
+ givenGradleVersionIsCompatibleWithCurrentJvm(gradleVersion);
+ givenGradleVersionIsSupportedByCurrentGradleTestKit(gradleVersion);
+ givenGradleProjectFiles(projectName);
+ givenGradleProjectProperties();
+ ensureDependenciesDownloaded(gradleVersion);
+
+ BuildResult buildResult = runGradleTests(gradleVersion, true, false);
+ assertBuildSuccessful(buildResult);
+
+ verifyEventsAndCoverages(
+ projectName,
+ "gradle",
+ gradleVersion,
+ mockBackend.waitForEvents(expectedTraces),
+ mockBackend.waitForCoverages(0));
+ }
+
// TODO: add back LATEST_GRADLE_VERSION after fixing ordering on Gradle 9.3.0
@TableTest({
"scenario | gradleVersion | projectName | flakyTests | expectedOrder | eventsNumber",
diff --git a/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/build.gradleTest b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/build.gradleTest
new file mode 100644
index 00000000000..43e1b6068d5
--- /dev/null
+++ b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/build.gradleTest
@@ -0,0 +1,108 @@
+import java.util.zip.ZipFile
+import org.gradle.api.artifacts.transform.InputArtifact
+import org.gradle.api.artifacts.transform.TransformAction
+import org.gradle.api.artifacts.transform.TransformOutputs
+import org.gradle.api.artifacts.transform.TransformParameters
+import org.gradle.api.attributes.AttributeCompatibilityRule
+import org.gradle.api.attributes.CompatibilityCheckDetails
+import org.gradle.api.attributes.LibraryElements
+
+apply plugin: 'java'
+
+repositories {
+ mavenLocal()
+
+ def proxyUrl = System.getenv("MAVEN_REPOSITORY_PROXY")
+ if (proxyUrl) {
+ println "Using proxy repository: $proxyUrl"
+ maven {
+ url = proxyUrl
+ allowInsecureProtocol = true
+ }
+ }
+
+ mavenCentral()
+
+ // androidx.test artifacts (Robolectric runtime dependencies) are published only to Google's Maven
+ // repository. Scope it strictly to androidx.* so nothing else resolves from it.
+ maven {
+ url = 'https://maven.google.com'
+ content {
+ includeGroupByRegex 'androidx\\..*'
+ }
+ }
+}
+
+// androidx.test ships as Android archives (.aar), which a plain-JVM build cannot consume directly.
+// Teach Gradle to accept the aar variant and extract its classes.jar — the part the Android Gradle
+// plugin would otherwise do. Safe to apply broadly here: this is a standalone project with no
+// class-directory (project) dependencies.
+abstract class AarToJarCompatibility implements AttributeCompatibilityRule {
+ @Override
+ void execute(CompatibilityCheckDetails details) {
+ if (details.producerValue != null && details.producerValue.name == 'aar') {
+ details.compatible()
+ }
+ }
+}
+
+abstract class ExtractAarClasses implements TransformAction {
+ @InputArtifact
+ abstract Provider getInputArtifact()
+
+ @Override
+ void transform(TransformOutputs outputs) {
+ def aar = inputArtifact.get().asFile
+ if (!aar.name.endsWith('.aar')) {
+ outputs.file(aar)
+ return
+ }
+ new ZipFile(aar).withCloseable { zip ->
+ def entry = zip.getEntry('classes.jar')
+ def out = outputs.file(aar.name - '.aar' + '.jar')
+ if (entry != null) {
+ out.withOutputStream { os -> zip.getInputStream(entry).withCloseable { input -> os << input } }
+ } else {
+ new java.util.jar.JarOutputStream(out.newOutputStream()).close()
+ }
+ }
+ }
+}
+
+def artifactType = Attribute.of('artifactType', String)
+
+dependencies {
+ attributesSchema {
+ attribute(LibraryElements.LIBRARY_ELEMENTS_ATTRIBUTE) {
+ compatibilityRules.add(AarToJarCompatibility)
+ }
+ }
+ registerTransform(ExtractAarClasses) {
+ from.attribute(artifactType, 'aar')
+ to.attribute(artifactType, 'jar')
+ }
+}
+
+configurations.configureEach {
+ if (canBeResolved) {
+ attributes.attribute(artifactType, 'jar')
+ }
+}
+
+dependencies {
+ testImplementation 'junit:junit:4.13.2'
+ testImplementation 'org.robolectric:robolectric:4.16.1'
+ // Pre-built Android SDK jar for the level the fixtures configure.
+ testImplementation 'org.robolectric:android-all:14-robolectric-10818077'
+ // androidx.test:core pulls in androidx.test:monitor (InstrumentationRegistry); ext:junit provides
+ // the AndroidJUnit4 runner, which delegates to RobolectricTestRunner for local (host-JVM) tests.
+ testImplementation 'androidx.test:core:1.6.1'
+ testImplementation 'androidx.test.ext:junit:1.2.1'
+}
+
+test {
+ useJUnit()
+ // Coverage is validated by the other Gradle smoke scenarios; disable it here so this scenario
+ // stays focused on the emulated-SDK (test.android.*) tags.
+ environment "DD_CIVISIBILITY_CODE_COVERAGE_ENABLED", "false"
+}
diff --git a/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/coverages.ftl b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/coverages.ftl
new file mode 100644
index 00000000000..8878e547a79
--- /dev/null
+++ b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/coverages.ftl
@@ -0,0 +1 @@
+[ ]
\ No newline at end of file
diff --git a/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/events.ftl b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/events.ftl
new file mode 100644
index 00000000000..a40de6565b2
--- /dev/null
+++ b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/events.ftl
@@ -0,0 +1,502 @@
+[ {
+ "content" : {
+ "duration" : ${content_duration},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid},
+ "_dd.test.is_user_provided_service" : "true",
+ "_dd.tracer_host" : ${content_meta__dd_tracer_host},
+ "ci.workspace_path" : ${content_meta_ci_workspace_path},
+ "component" : "gradle",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version},
+ "span.kind" : "test_session_end",
+ "test.code_coverage.enabled" : "true",
+ "test.command" : "gradle test",
+ "test.framework" : "junit4",
+ "test.framework_version" : "4.13.2",
+ "test.itr.tests_skipping.enabled" : "true",
+ "test.itr.tests_skipping.type" : "test",
+ "test.status" : "pass",
+ "test.toolchain" : ${content_meta_test_toolchain},
+ "test.type" : "test",
+ "test_session.name" : "gradle test"
+ },
+ "metrics" : {
+ "_dd.host.vcpu_count" : ${content_metrics__dd_host_vcpu_count},
+ "_dd.profiling.enabled" : 0,
+ "_dd.trace_span_attribute_schema" : 0,
+ "process_id" : ${content_metrics_process_id},
+ "test.itr.tests_skipping.count" : 0
+ },
+ "name" : "gradle.test_session",
+ "resource" : ":",
+ "service" : "test-gradle-service",
+ "start" : ${content_start},
+ "test_session_id" : ${content_test_session_id}
+ },
+ "type" : "test_session_end",
+ "version" : 1
+}, {
+ "content" : {
+ "duration" : ${content_duration_2},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid_2},
+ "_dd.test.is_user_provided_service" : "true",
+ "ci.workspace_path" : ${content_meta_ci_workspace_path},
+ "component" : "gradle",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version},
+ "span.kind" : "test_module_end",
+ "test.code_coverage.enabled" : "true",
+ "test.command" : "gradle test",
+ "test.framework" : "junit4",
+ "test.framework_version" : "4.13.2",
+ "test.itr.tests_skipping.enabled" : "true",
+ "test.itr.tests_skipping.type" : "test",
+ "test.module" : ":test",
+ "test.status" : "pass",
+ "test.type" : "test",
+ "test_session.name" : "gradle test"
+ },
+ "metrics" : {
+ "_dd.host.vcpu_count" : ${content_metrics__dd_host_vcpu_count_2},
+ "test.itr.tests_skipping.count" : 0
+ },
+ "name" : "gradle.test_module",
+ "resource" : ":test",
+ "service" : "test-gradle-service",
+ "start" : ${content_start_2},
+ "test_module_id" : ${content_test_module_id},
+ "test_session_id" : ${content_test_session_id}
+ },
+ "type" : "test_module_end",
+ "version" : 1
+}, {
+ "content" : {
+ "duration" : ${content_duration_3},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid_3},
+ "_dd.test.is_user_provided_service" : "true",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version}
+ },
+ "metrics" : { },
+ "name" : "classes",
+ "parent_id" : ${content_test_session_id},
+ "resource" : "classes",
+ "service" : "test-gradle-service",
+ "span_id" : ${content_span_id},
+ "start" : ${content_start_3},
+ "trace_id" : ${content_test_session_id}
+ },
+ "type" : "span",
+ "version" : 1
+}, {
+ "content" : {
+ "duration" : ${content_duration_4},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid_4},
+ "_dd.test.is_user_provided_service" : "true",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version}
+ },
+ "metrics" : { },
+ "name" : "compileJava",
+ "parent_id" : ${content_test_session_id},
+ "resource" : "compileJava",
+ "service" : "test-gradle-service",
+ "span_id" : ${content_span_id_2},
+ "start" : ${content_start_4},
+ "trace_id" : ${content_test_session_id}
+ },
+ "type" : "span",
+ "version" : 1
+}, {
+ "content" : {
+ "duration" : ${content_duration_5},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid_5},
+ "_dd.test.is_user_provided_service" : "true",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version}
+ },
+ "metrics" : { },
+ "name" : "compileTestJava",
+ "parent_id" : ${content_test_session_id},
+ "resource" : "compileTestJava",
+ "service" : "test-gradle-service",
+ "span_id" : ${content_span_id_3},
+ "start" : ${content_start_5},
+ "trace_id" : ${content_test_session_id}
+ },
+ "type" : "span",
+ "version" : 1
+}, {
+ "content" : {
+ "duration" : ${content_duration_6},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid_6},
+ "_dd.test.is_user_provided_service" : "true",
+ "_dd.tracer_host" : ${content_meta__dd_tracer_host},
+ "ci.workspace_path" : ${content_meta_ci_workspace_path},
+ "component" : "junit4",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id_2},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version},
+ "span.kind" : "test_suite_end",
+ "test.framework" : "junit4",
+ "test.framework_version" : "4.13.2",
+ "test.itr.tests_skipping.enabled" : "true",
+ "test.module" : ":test",
+ "test.source.file" : "src/test/java/datadog/smoke/AndroidJUnit4RunnerTest.java",
+ "test.status" : "pass",
+ "test.suite" : "datadog.smoke.AndroidJUnit4RunnerTest",
+ "test.type" : "test",
+ "test_session.name" : "gradle test"
+ },
+ "metrics" : {
+ "_dd.host.vcpu_count" : ${content_metrics__dd_host_vcpu_count_3},
+ "_dd.profiling.enabled" : 0,
+ "_dd.trace_span_attribute_schema" : 0,
+ "process_id" : ${content_metrics_process_id_2},
+ "test.source.end" : 21,
+ "test.source.start" : 13
+ },
+ "name" : "junit4.test_suite",
+ "resource" : "datadog.smoke.AndroidJUnit4RunnerTest",
+ "service" : "test-gradle-service",
+ "start" : ${content_start_6},
+ "test_module_id" : ${content_test_module_id},
+ "test_session_id" : ${content_test_session_id},
+ "test_suite_id" : ${content_test_suite_id}
+ },
+ "type" : "test_suite_end",
+ "version" : 1
+}, {
+ "content" : {
+ "duration" : ${content_duration_7},
+ "error" : 0,
+ "meta" : {
+ "_dd.library_capabilities.auto_test_retries" : "1",
+ "_dd.library_capabilities.coverage_report_upload" : "1",
+ "_dd.library_capabilities.early_flake_detection" : "1",
+ "_dd.library_capabilities.fail_fast_test_order" : "1",
+ "_dd.library_capabilities.failed_test_replay" : "1",
+ "_dd.library_capabilities.impacted_tests" : "1",
+ "_dd.library_capabilities.test_impact_analysis" : "1",
+ "_dd.library_capabilities.test_management.attempt_to_fix" : "5",
+ "_dd.library_capabilities.test_management.disable" : "1",
+ "_dd.library_capabilities.test_management.quarantine" : "1",
+ "_dd.p.tid" : ${content_meta__dd_p_tid_7},
+ "_dd.test.is_user_provided_service" : "true",
+ "_dd.tracer_host" : ${content_meta__dd_tracer_host},
+ "ci.workspace_path" : ${content_meta_ci_workspace_path},
+ "component" : "junit4",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id_2},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version},
+ "span.kind" : "test",
+ "test.android.codename" : "U",
+ "test.android.release" : "14",
+ "test.android.robolectric.version" : "4.16.1",
+ "test.final_status" : "pass",
+ "test.framework" : "junit4",
+ "test.framework_version" : "4.13.2",
+ "test.itr.tests_skipping.enabled" : "true",
+ "test.module" : ":test",
+ "test.name" : "test_androidjunit4_runner",
+ "test.source.file" : "src/test/java/datadog/smoke/AndroidJUnit4RunnerTest.java",
+ "test.source.method" : "test_androidjunit4_runner()V",
+ "test.status" : "pass",
+ "test.suite" : "datadog.smoke.AndroidJUnit4RunnerTest",
+ "test.type" : "test",
+ "test_session.name" : "gradle test"
+ },
+ "metrics" : {
+ "_dd.host.vcpu_count" : ${content_metrics__dd_host_vcpu_count_4},
+ "_dd.profiling.enabled" : 0,
+ "_dd.trace_span_attribute_schema" : 0,
+ "process_id" : ${content_metrics_process_id_2},
+ "test.android.api_level" : 34,
+ "test.source.end" : 20,
+ "test.source.start" : 17
+ },
+ "name" : "junit4.test",
+ "parent_id" : ${content_parent_id},
+ "resource" : "datadog.smoke.AndroidJUnit4RunnerTest.test_androidjunit4_runner",
+ "service" : "test-gradle-service",
+ "span_id" : ${content_span_id_4},
+ "start" : ${content_start_7},
+ "test_module_id" : ${content_test_module_id},
+ "test_session_id" : ${content_test_session_id},
+ "test_suite_id" : ${content_test_suite_id},
+ "trace_id" : ${content_trace_id}
+ },
+ "type" : "test",
+ "version" : 2
+}, {
+ "content" : {
+ "duration" : ${content_duration_8},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid_8},
+ "_dd.test.is_user_provided_service" : "true",
+ "_dd.tracer_host" : ${content_meta__dd_tracer_host},
+ "ci.workspace_path" : ${content_meta_ci_workspace_path},
+ "component" : "junit4",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id_2},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version},
+ "span.kind" : "test_suite_end",
+ "test.framework" : "junit4",
+ "test.framework_version" : "4.13.2",
+ "test.itr.tests_skipping.enabled" : "true",
+ "test.module" : ":test",
+ "test.source.file" : "src/test/java/datadog/smoke/RobolectricRunnerTest.java",
+ "test.status" : "pass",
+ "test.suite" : "datadog.smoke.RobolectricRunnerTest",
+ "test.type" : "test",
+ "test_session.name" : "gradle test"
+ },
+ "metrics" : {
+ "_dd.host.vcpu_count" : ${content_metrics__dd_host_vcpu_count_5},
+ "_dd.profiling.enabled" : 0,
+ "_dd.trace_span_attribute_schema" : 0,
+ "process_id" : ${content_metrics_process_id_2},
+ "test.source.end" : 19,
+ "test.source.start" : 11
+ },
+ "name" : "junit4.test_suite",
+ "resource" : "datadog.smoke.RobolectricRunnerTest",
+ "service" : "test-gradle-service",
+ "start" : ${content_start_8},
+ "test_module_id" : ${content_test_module_id},
+ "test_session_id" : ${content_test_session_id},
+ "test_suite_id" : ${content_test_suite_id_2}
+ },
+ "type" : "test_suite_end",
+ "version" : 1
+}, {
+ "content" : {
+ "duration" : ${content_duration_9},
+ "error" : 0,
+ "meta" : {
+ "_dd.library_capabilities.auto_test_retries" : "1",
+ "_dd.library_capabilities.coverage_report_upload" : "1",
+ "_dd.library_capabilities.early_flake_detection" : "1",
+ "_dd.library_capabilities.fail_fast_test_order" : "1",
+ "_dd.library_capabilities.failed_test_replay" : "1",
+ "_dd.library_capabilities.impacted_tests" : "1",
+ "_dd.library_capabilities.test_impact_analysis" : "1",
+ "_dd.library_capabilities.test_management.attempt_to_fix" : "5",
+ "_dd.library_capabilities.test_management.disable" : "1",
+ "_dd.library_capabilities.test_management.quarantine" : "1",
+ "_dd.p.tid" : ${content_meta__dd_p_tid_9},
+ "_dd.test.is_user_provided_service" : "true",
+ "_dd.tracer_host" : ${content_meta__dd_tracer_host},
+ "ci.workspace_path" : ${content_meta_ci_workspace_path},
+ "component" : "junit4",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id_2},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version},
+ "span.kind" : "test",
+ "test.android.codename" : "U",
+ "test.android.release" : "14",
+ "test.android.robolectric.version" : "4.16.1",
+ "test.final_status" : "pass",
+ "test.framework" : "junit4",
+ "test.framework_version" : "4.13.2",
+ "test.itr.tests_skipping.enabled" : "true",
+ "test.module" : ":test",
+ "test.name" : "test_robolectric_runner",
+ "test.source.file" : "src/test/java/datadog/smoke/RobolectricRunnerTest.java",
+ "test.source.method" : "test_robolectric_runner()V",
+ "test.status" : "pass",
+ "test.suite" : "datadog.smoke.RobolectricRunnerTest",
+ "test.type" : "test",
+ "test_session.name" : "gradle test"
+ },
+ "metrics" : {
+ "_dd.host.vcpu_count" : ${content_metrics__dd_host_vcpu_count_6},
+ "_dd.profiling.enabled" : 0,
+ "_dd.trace_span_attribute_schema" : 0,
+ "process_id" : ${content_metrics_process_id_2},
+ "test.android.api_level" : 34,
+ "test.source.end" : 18,
+ "test.source.start" : 15
+ },
+ "name" : "junit4.test",
+ "parent_id" : ${content_parent_id},
+ "resource" : "datadog.smoke.RobolectricRunnerTest.test_robolectric_runner",
+ "service" : "test-gradle-service",
+ "span_id" : ${content_span_id_5},
+ "start" : ${content_start_9},
+ "test_module_id" : ${content_test_module_id},
+ "test_session_id" : ${content_test_session_id},
+ "test_suite_id" : ${content_test_suite_id_2},
+ "trace_id" : ${content_trace_id_2}
+ },
+ "type" : "test",
+ "version" : 2
+}, {
+ "content" : {
+ "duration" : ${content_duration_10},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid_10},
+ "_dd.test.is_user_provided_service" : "true",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version}
+ },
+ "metrics" : { },
+ "name" : "processResources",
+ "parent_id" : ${content_test_session_id},
+ "resource" : "processResources",
+ "service" : "test-gradle-service",
+ "span_id" : ${content_span_id_6},
+ "start" : ${content_start_10},
+ "trace_id" : ${content_test_session_id}
+ },
+ "type" : "span",
+ "version" : 1
+}, {
+ "content" : {
+ "duration" : ${content_duration_11},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid_11},
+ "_dd.test.is_user_provided_service" : "true",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version}
+ },
+ "metrics" : { },
+ "name" : "processTestResources",
+ "parent_id" : ${content_test_session_id},
+ "resource" : "processTestResources",
+ "service" : "test-gradle-service",
+ "span_id" : ${content_span_id_7},
+ "start" : ${content_start_11},
+ "trace_id" : ${content_test_session_id}
+ },
+ "type" : "span",
+ "version" : 1
+}, {
+ "content" : {
+ "duration" : ${content_duration_12},
+ "error" : 0,
+ "meta" : {
+ "_dd.p.tid" : ${content_meta__dd_p_tid_12},
+ "_dd.test.is_user_provided_service" : "true",
+ "env" : "integration-test",
+ "language" : "jvm",
+ "library_version" : ${content_meta_library_version},
+ "os.architecture" : ${content_meta_os_architecture},
+ "os.platform" : ${content_meta_os_platform},
+ "os.version" : ${content_meta_os_version},
+ "runtime-id" : ${content_meta_runtime_id},
+ "runtime.name" : ${content_meta_runtime_name},
+ "runtime.vendor" : ${content_meta_runtime_vendor},
+ "runtime.version" : ${content_meta_runtime_version}
+ },
+ "metrics" : { },
+ "name" : "testClasses",
+ "parent_id" : ${content_test_session_id},
+ "resource" : "testClasses",
+ "service" : "test-gradle-service",
+ "span_id" : ${content_span_id_8},
+ "start" : ${content_start_12},
+ "trace_id" : ${content_test_session_id}
+ },
+ "type" : "span",
+ "version" : 1
+} ]
\ No newline at end of file
diff --git a/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/settings.gradleTest b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/settings.gradleTest
new file mode 100644
index 00000000000..6040f1decb8
--- /dev/null
+++ b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/settings.gradleTest
@@ -0,0 +1 @@
+rootProject.name = 'gradle-instrumentation-test-project'
diff --git a/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/src/test/java/datadog/smoke/AndroidJUnit4RunnerTest.java b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/src/test/java/datadog/smoke/AndroidJUnit4RunnerTest.java
new file mode 100644
index 00000000000..a85709853b1
--- /dev/null
+++ b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/src/test/java/datadog/smoke/AndroidJUnit4RunnerTest.java
@@ -0,0 +1,19 @@
+package datadog.smoke;
+
+import static org.junit.Assert.assertEquals;
+
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(AndroidJUnit4.class)
+@Config(sdk = 34, manifest = Config.NONE)
+public class AndroidJUnit4RunnerTest {
+
+ @Test
+ public void test_androidjunit4_runner() {
+ assertEquals(34, RuntimeEnvironment.getApiLevel());
+ }
+}
diff --git a/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/src/test/java/datadog/smoke/RobolectricRunnerTest.java b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/src/test/java/datadog/smoke/RobolectricRunnerTest.java
new file mode 100644
index 00000000000..d034022dd5d
--- /dev/null
+++ b/dd-smoke-tests/gradle/src/test/resources/test-succeed-robolectric/src/test/java/datadog/smoke/RobolectricRunnerTest.java
@@ -0,0 +1,19 @@
+package datadog.smoke;
+
+import static org.junit.Assert.assertEquals;
+
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.robolectric.RobolectricTestRunner;
+import org.robolectric.RuntimeEnvironment;
+import org.robolectric.annotation.Config;
+
+@RunWith(RobolectricTestRunner.class)
+@Config(sdk = 34, manifest = Config.NONE)
+public class RobolectricRunnerTest {
+
+ @Test
+ public void test_robolectric_runner() {
+ assertEquals(34, RuntimeEnvironment.getApiLevel());
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/civisibility/android/AndroidTestContext.java b/internal-api/src/main/java/datadog/trace/api/civisibility/android/AndroidTestContext.java
new file mode 100644
index 00000000000..41d25f1b4c4
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/civisibility/android/AndroidTestContext.java
@@ -0,0 +1,42 @@
+package datadog.trace.api.civisibility.android;
+
+import javax.annotation.Nullable;
+
+/**
+ * Thread-local hand-off for {@link AndroidTestInfo} between the Robolectric instrumentation and the
+ * CI Visibility core.
+ *
+ * Robolectric runs each test on a per-SDK sandbox "main" thread, and the emulated SDK is only
+ * established while the test body executes. The Robolectric instrumentation captures the SDK on
+ * that sandbox thread (via {@link #set}) while it is available, and the core drains it (via {@link
+ * #getAndClear}) on the same thread when the test finishes.
+ */
+public abstract class AndroidTestContext {
+
+ private static final ThreadLocal CURRENT = new ThreadLocal<>();
+
+ private AndroidTestContext() {}
+
+ /** Records the emulated Android environment for the test executing on the current thread. */
+ public static void set(AndroidTestInfo info) {
+ CURRENT.set(info);
+ }
+
+ /**
+ * Returns and clears the info recorded for the current thread, or {@code null} if none (the
+ * common, non-Robolectric case).
+ */
+ @Nullable
+ public static AndroidTestInfo getAndClear() {
+ AndroidTestInfo info = CURRENT.get();
+ if (info != null) {
+ CURRENT.remove();
+ }
+ return info;
+ }
+
+ /** Clears any info recorded for the current thread. */
+ public static void clear() {
+ CURRENT.remove();
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/civisibility/android/AndroidTestInfo.java b/internal-api/src/main/java/datadog/trace/api/civisibility/android/AndroidTestInfo.java
new file mode 100644
index 00000000000..e834b6711c5
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/civisibility/android/AndroidTestInfo.java
@@ -0,0 +1,46 @@
+package datadog.trace.api.civisibility.android;
+
+import javax.annotation.Nullable;
+
+/** Immutable snapshot of the emulated Android SDK a test ran against (e.g. under Robolectric). */
+public final class AndroidTestInfo {
+
+ private final int apiLevel;
+ @Nullable private final String release;
+ @Nullable private final String codename;
+ @Nullable private final String robolectricVersion;
+
+ public AndroidTestInfo(
+ int apiLevel,
+ @Nullable String release,
+ @Nullable String codename,
+ @Nullable String robolectricVersion) {
+ this.apiLevel = apiLevel;
+ this.release = release;
+ this.codename = codename;
+ this.robolectricVersion = robolectricVersion;
+ }
+
+ /** The emulated Android API level (e.g. {@code 34}). */
+ public int getApiLevel() {
+ return apiLevel;
+ }
+
+ /** The Android release the API level maps to (e.g. {@code "14"}), or {@code null} if unknown. */
+ @Nullable
+ public String getRelease() {
+ return release;
+ }
+
+ /** The Android release short code (e.g. {@code "U"}), or {@code null} if unknown. */
+ @Nullable
+ public String getCodename() {
+ return codename;
+ }
+
+ /** The Robolectric version driving the test (e.g. {@code "4.16.1"}), or {@code null}. */
+ @Nullable
+ public String getRobolectricVersion() {
+ return robolectricVersion;
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/api/civisibility/telemetry/CiVisibilityCountMetric.java b/internal-api/src/main/java/datadog/trace/api/civisibility/telemetry/CiVisibilityCountMetric.java
index 2f4336c3711..e4d5d38c8d2 100644
--- a/internal-api/src/main/java/datadog/trace/api/civisibility/telemetry/CiVisibilityCountMetric.java
+++ b/internal-api/src/main/java/datadog/trace/api/civisibility/telemetry/CiVisibilityCountMetric.java
@@ -22,6 +22,7 @@
import datadog.trace.api.civisibility.telemetry.tag.HasCodeowner;
import datadog.trace.api.civisibility.telemetry.tag.HasFailedAllRetries;
import datadog.trace.api.civisibility.telemetry.tag.ImpactedTestsDetectionEnabled;
+import datadog.trace.api.civisibility.telemetry.tag.IsAndroid;
import datadog.trace.api.civisibility.telemetry.tag.IsAttemptToFix;
import datadog.trace.api.civisibility.telemetry.tag.IsDisabled;
import datadog.trace.api.civisibility.telemetry.tag.IsHeadless;
@@ -88,7 +89,8 @@ public enum CiVisibilityCountMetric {
RetryReason.class,
FailedTestReplayEnabled.TestMetric.class,
IsRum.class,
- BrowserDriver.class),
+ BrowserDriver.class,
+ IsAndroid.class),
/** The number of successfully collected code coverages that are empty */
CODE_COVERAGE_IS_EMPTY("code_coverage.is_empty"),
/** The number of errors while processing code coverage */
diff --git a/internal-api/src/main/java/datadog/trace/api/civisibility/telemetry/tag/IsAndroid.java b/internal-api/src/main/java/datadog/trace/api/civisibility/telemetry/tag/IsAndroid.java
new file mode 100644
index 00000000000..e39baaa8d52
--- /dev/null
+++ b/internal-api/src/main/java/datadog/trace/api/civisibility/telemetry/tag/IsAndroid.java
@@ -0,0 +1,13 @@
+package datadog.trace.api.civisibility.telemetry.tag;
+
+import datadog.trace.api.civisibility.telemetry.TagValue;
+
+/** Whether a test ran against an emulated Android SDK (e.g. under Robolectric). */
+public enum IsAndroid implements TagValue {
+ TRUE;
+
+ @Override
+ public String asString() {
+ return "is_android:true";
+ }
+}
diff --git a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java
index 14496e8b243..968d6eea3b3 100644
--- a/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java
+++ b/internal-api/src/main/java/datadog/trace/bootstrap/instrumentation/api/Tags.java
@@ -85,6 +85,13 @@ public class Tags {
public static final String TEST_BROWSER_NAME = "test.browser.name";
public static final String TEST_BROWSER_VERSION = "test.browser.version";
public static final String TEST_CALLBACK = "test.callback";
+ // Emulated Android SDK metadata. Robolectric runs tests on the host JVM against an emulated SDK,
+ // so the host os.*/runtime.* tags do not describe it. The SDK tags are per-test, since
+ // @Config(sdk=...) can vary per method.
+ public static final String TEST_ANDROID_API_LEVEL = "test.android.api_level";
+ public static final String TEST_ANDROID_RELEASE = "test.android.release";
+ public static final String TEST_ANDROID_CODENAME = "test.android.codename";
+ public static final String TEST_ANDROID_ROBOLECTRIC_VERSION = "test.android.robolectric.version";
public static final String TEST_SESSION_ID = "test_session_id";
public static final String TEST_MODULE_ID = "test_module_id";
diff --git a/internal-api/src/test/java/datadog/trace/api/civisibility/android/AndroidTestContextTest.java b/internal-api/src/test/java/datadog/trace/api/civisibility/android/AndroidTestContextTest.java
new file mode 100644
index 00000000000..41d0492cf7c
--- /dev/null
+++ b/internal-api/src/test/java/datadog/trace/api/civisibility/android/AndroidTestContextTest.java
@@ -0,0 +1,45 @@
+package datadog.trace.api.civisibility.android;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.Test;
+
+class AndroidTestContextTest {
+
+ @AfterEach
+ void reset() {
+ AndroidTestContext.clear();
+ }
+
+ @Test
+ void getAndClearReturnsTheStoredInfoOnce() {
+ AndroidTestInfo info = new AndroidTestInfo(34, "14", "U", "4.16.1");
+ AndroidTestContext.set(info);
+
+ AndroidTestInfo drained = AndroidTestContext.getAndClear();
+ assertNotNull(drained);
+ assertEquals(info, drained);
+ assertEquals(34, drained.getApiLevel());
+ assertEquals("14", drained.getRelease());
+ assertEquals("U", drained.getCodename());
+ assertEquals("4.16.1", drained.getRobolectricVersion());
+
+ // A second drain returns nothing: the value is consumed.
+ assertNull(AndroidTestContext.getAndClear());
+ }
+
+ @Test
+ void getAndClearReturnsNullWhenNothingRecorded() {
+ assertNull(AndroidTestContext.getAndClear());
+ }
+
+ @Test
+ void clearRemovesTheStoredInfo() {
+ AndroidTestContext.set(new AndroidTestInfo(30, null, null, null));
+ AndroidTestContext.clear();
+ assertNull(AndroidTestContext.getAndClear());
+ }
+}
diff --git a/metadata/supported-configurations.json b/metadata/supported-configurations.json
index 60dae10c020..019d5605ae8 100644
--- a/metadata/supported-configurations.json
+++ b/metadata/supported-configurations.json
@@ -9537,6 +9537,14 @@
"aliases": ["DD_TRACE_INTEGRATION_RMI_ENABLED", "DD_INTEGRATION_RMI_ENABLED"]
}
],
+ "DD_TRACE_ROBOLECTRIC_ENABLED": [
+ {
+ "version": "A",
+ "type": "boolean",
+ "default": "true",
+ "aliases": ["DD_TRACE_INTEGRATION_ROBOLECTRIC_ENABLED", "DD_INTEGRATION_ROBOLECTRIC_ENABLED"]
+ }
+ ],
"DD_TRACE_RMI_SERVER_ANALYTICS_ENABLED": [
{
"version": "A",
diff --git a/settings.gradle.kts b/settings.gradle.kts
index 53fe2bff423..73f2297330e 100644
--- a/settings.gradle.kts
+++ b/settings.gradle.kts
@@ -540,6 +540,7 @@ include(
":dd-java-agent:instrumentation:reactor-core-3.1",
":dd-java-agent:instrumentation:reactor-netty-1.0",
":dd-java-agent:instrumentation:rediscala-1.8",
+ ":dd-java-agent:instrumentation:robolectric:robolectric-4.11",
":dd-java-agent:instrumentation:redisson:redisson-2.0.0",
":dd-java-agent:instrumentation:redisson:redisson-2.3.0",
":dd-java-agent:instrumentation:redisson:redisson-3.10.3",