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