diff --git a/packages/core/RNSentryAndroidTester/app/build.gradle b/packages/core/RNSentryAndroidTester/app/build.gradle
index 9a1ba87087..391780423a 100644
--- a/packages/core/RNSentryAndroidTester/app/build.gradle
+++ b/packages/core/RNSentryAndroidTester/app/build.gradle
@@ -34,6 +34,14 @@ android {
testLogging {
events 'passed', 'skipped', 'failed', 'standardOut', 'standardError'
}
+ // Absolute path to the script plugin under test and the Android SDK, so the GradleTestKit
+ // functional tests can apply the real sentry.gradle.kts and point a fixture build at the SDK.
+ systemProperty 'sentry.gradle.script', new File(rootDir, '../sentry.gradle.kts').absolutePath
+ systemProperty 'sentry.android.sdkDir', android.sdkDirectory.absolutePath
+ // AGP version and compileSdk the fixtures should use, taken from THIS host build so they track
+ // it instead of pinning a stale AGP/SDK that silently diverges when the host is bumped.
+ systemProperty 'sentry.android.agpVersion', com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION
+ systemProperty 'sentry.android.compileSdk', android.compileSdk.toString()
}
}
@@ -48,6 +56,7 @@ dependencies {
implementation 'com.google.android.material:material:1.5.0'
implementation 'androidx.test:core-ktx:1.6.1'
testImplementation 'junit:junit:4.13.2'
+ testImplementation gradleTestKit()
testImplementation 'org.mockito:mockito-core:5.10.0'
testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1'
testImplementation 'org.robolectric:robolectric:4.14.1'
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/gradle/BaseSentryGradleTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/gradle/BaseSentryGradleTest.kt
new file mode 100644
index 0000000000..15ec772728
--- /dev/null
+++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/gradle/BaseSentryGradleTest.kt
@@ -0,0 +1,80 @@
+package io.sentry.react.gradle
+
+import org.gradle.testkit.runner.GradleRunner
+import org.junit.Rule
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+/**
+ * Shared GradleTestKit scaffolding for the `sentry.gradle.kts` script-plugin functional tests.
+ *
+ * Both concrete tests stand up a minimal `com.android.application` fixture in a temp dir, apply the
+ * real script, and drive tasks through GradleTestKit. The parts that don't vary between them live
+ * here: the system properties injected by the host build (`app/build.gradle`), the invariant fixture
+ * files (settings, local.properties, stub manifest), path escaping, and a runner with the host
+ * `SENTRY_*` environment scrubbed for determinism.
+ *
+ * The AGP version and `compileSdk` come from the host build too (not hardcoded), so the fixtures track
+ * whatever the host is on instead of pinning a stale toolchain that silently diverges on a bump.
+ */
+abstract class BaseSentryGradleTest {
+ @get:Rule
+ val tempFolder = TemporaryFolder()
+
+ protected val scriptPath: String =
+ System.getProperty("sentry.gradle.script") ?: error("sentry.gradle.script system property not set")
+ protected val sdkDir: String =
+ System.getProperty("sentry.android.sdkDir") ?: error("sentry.android.sdkDir system property not set")
+ protected val agpVersion: String =
+ System.getProperty("sentry.android.agpVersion") ?: error("sentry.android.agpVersion system property not set")
+ protected val compileSdk: String =
+ System.getProperty("sentry.android.compileSdk") ?: error("sentry.android.compileSdk system property not set")
+
+ protected lateinit var projectDir: File
+
+ /** Escape Windows path separators so an absolute path is safe inside a Groovy string literal. */
+ protected fun String.esc(): String = replace("\\", "\\\\")
+
+ /** Write the fixture files that are identical across every test: settings, local.properties, manifest. */
+ protected fun writeCommonFixture() {
+ File(projectDir, "settings.gradle").writeText(
+ """
+ pluginManagement {
+ repositories {
+ google()
+ mavenCentral()
+ gradlePluginPortal()
+ }
+ }
+ dependencyResolutionManagement {
+ repositories {
+ google()
+ mavenCentral()
+ }
+ }
+ rootProject.name = "fixture"
+ """.trimIndent(),
+ )
+
+ File(projectDir, "local.properties").writeText("sdk.dir=${sdkDir.esc()}")
+
+ val manifestDir = File(projectDir, "src/main")
+ manifestDir.mkdirs()
+ File(manifestDir, "AndroidManifest.xml").writeText("")
+ }
+
+ /**
+ * A [GradleRunner] for [projectDir] with the host `SENTRY_*` variables scrubbed (the script reads
+ * several at configuration time), plus any [extraEnv] a specific test needs.
+ */
+ protected fun baseRunner(
+ vararg args: String,
+ extraEnv: Map = emptyMap(),
+ ): GradleRunner =
+ GradleRunner
+ .create()
+ .withProjectDir(projectDir)
+ .withArguments(*args, "--stacktrace")
+ .withEnvironment(System.getenv().filterKeys { !it.startsWith("SENTRY_") } + extraEnv)
+ .forwardOutput()
+}
diff --git a/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/gradle/SentryModulesTaskTest.kt b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/gradle/SentryModulesTaskTest.kt
new file mode 100644
index 0000000000..c6b55339c0
--- /dev/null
+++ b/packages/core/RNSentryAndroidTester/app/src/test/java/io/sentry/react/gradle/SentryModulesTaskTest.kt
@@ -0,0 +1,290 @@
+package io.sentry.react.gradle
+
+import org.gradle.testkit.runner.TaskOutcome
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertTrue
+import org.junit.Test
+import java.io.File
+
+/**
+ * Functional tests for the `CollectModulesTask` wiring declared in `sentry.gradle.kts` (issue #6750 /
+ * PR #6753): `modules.json` must be generated into the build folder and registered as a generated
+ * assets source, never written into the version-controlled `src/main/assets` tree.
+ *
+ * Unlike the options task, the modules task lives inside `processVariant`, which requires a React
+ * Native bundle task (`createBundleJsAndAssets`) that exposes an extractable
+ * `--sourcemap-output`. The fixture stubs that bundle task with a tiny shell script that writes a
+ * source map, and points `collectModulesScript` at a fake node script so no real JS bundle is needed.
+ * Shared scaffolding lives in [BaseSentryGradleTest].
+ */
+class SentryModulesTaskTest : BaseSentryGradleTest() {
+ private val modulesTaskPath = ":createBundleReleaseJsAndAssets_SentryCollectModules"
+
+ /** All generated `modules.json` files under the build tree (AGP relocates the task output dir). */
+ private fun generatedModules(): List = File(projectDir, "build").walkTopDown().filter { it.name == "modules.json" }.toList()
+
+ private fun srcAssetsModules(): File = File(projectDir, "src/main/assets/modules.json")
+
+ /** The source map the bundle stub writes (and the upload cleanup deletes). */
+ private fun sourcemapFile(): File = File(projectDir, "build/generated/sourcemaps/react/release/index.android.bundle.map")
+
+ private fun writeFixture(
+ skipCollectModules: Boolean = false,
+ produceSourcemap: Boolean = true,
+ additionalBuildTypesBlock: String = "",
+ additionalTasksBlock: String = "",
+ bundleDeclaresOutputs: Boolean = false,
+ ) {
+ projectDir = tempFolder.newFolder("android")
+ writeCommonFixture()
+
+ // Shell stub for the RN bundle task: parses `--bundle-output`/`--sourcemap-output` out of its
+ // args and writes a minimal bundle + source map there, mimicking the real bundle task's
+ // observable outputs. The modules task fingerprints the bundle (its stable up-to-date key), so
+ // the stub must produce it, not only the source map.
+ val makeSourcemap = File(projectDir, "make-sourcemap.sh")
+ makeSourcemap.writeText(
+ """
+ #!/bin/sh
+ out=""
+ bundle=""
+ while [ ${'$'}# -gt 0 ]; do
+ if [ "${'$'}1" = "--sourcemap-output" ]; then out="${'$'}2"; fi
+ if [ "${'$'}1" = "--bundle-output" ]; then bundle="${'$'}2"; fi
+ shift
+ done
+ if [ -n "${'$'}bundle" ]; then
+ mkdir -p "${'$'}(dirname "${'$'}bundle")"
+ printf '//bundle' > "${'$'}bundle"
+ fi
+ if [ -n "${'$'}out" ]; then
+ mkdir -p "${'$'}(dirname "${'$'}out")"
+ printf '{"version":3,"sources":[]}' > "${'$'}out"
+ fi
+ """.trimIndent(),
+ )
+
+ // Fake collectModules script: `node