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
5 changes: 5 additions & 0 deletions packages/core/RNSentryAndroidTester/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ 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
}
}

Expand All @@ -48,6 +52,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'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
package io.sentry.react.gradle

import org.gradle.testkit.runner.GradleRunner
import org.gradle.testkit.runner.TaskOutcome
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
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 (`createBundle<Variant>JsAndAssets`) 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.
*/
class SentryModulesTaskTest {
@get:Rule
val tempFolder = TemporaryFolder()

private val scriptPath: String =
System.getProperty("sentry.gradle.script") ?: error("sentry.gradle.script system property not set")
private val sdkDir: String =
System.getProperty("sentry.android.sdkDir") ?: error("sentry.android.sdkDir system property not set")

private lateinit var projectDir: File

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> = 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")

// 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 <script> <sourcemap> <dest> <modulesPaths>` → writes dest.
val fakeCollect = File(projectDir, "fake-collect-modules.js")
fakeCollect.writeText(
"""
const fs = require('fs');
const dest = process.argv[3];
fs.mkdirSync(require('path').dirname(dest), { recursive: true });
fs.writeFileSync(dest, JSON.stringify({ 'fake-module': '1.0.0' }));
""".trimIndent(),
)

val bundleFile = File(projectDir, "build/generated/assets/react/release/index.android.bundle")
val sourcemapFile = File(projectDir, "build/generated/sourcemaps/react/release/index.android.bundle.map")

File(projectDir, "settings.gradle").writeText(
"""
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "fixture"
""".trimIndent(),
)

// Declare the bundle (but NOT the source map) as the bundle task's output so it can go
// UP-TO-DATE on rerun, reproducing the real RN bundle task: its forced source map is not a
// tracked output, so a deleted source map is never regenerated by an up-to-date bundle task.
val bundleOutputsBlock =
if (bundleDeclaresOutputs) {
"""
inputs.property('stub', '1')
outputs.file('${bundleFile.absolutePath.esc()}')
"""
} else {
""
}

val bundleTaskBlock =
if (produceSourcemap) {
"""
tasks.register("createBundleReleaseJsAndAssets", Exec) {
workingDir projectDir
executable 'sh'
args '${makeSourcemap.absolutePath.esc()}', '--bundle-output', '${bundleFile.absolutePath.esc()}', '--sourcemap-output', '${sourcemapFile.absolutePath.esc()}'
$bundleOutputsBlock
}
"""
} else {
// Bundle task exists (so processVariant proceeds) but produces no source map.
"""
tasks.register("createBundleReleaseJsAndAssets", Exec) {
workingDir projectDir
executable 'sh'
args '-c', 'true', '--bundle-output', '${bundleFile.absolutePath.esc()}', '--sourcemap-output', '${sourcemapFile.absolutePath.esc()}'
}
"""
}

File(projectDir, "build.gradle").writeText(
"""
plugins {
id 'com.android.application' version '8.3.2'
}
project.ext.sentryCli = [
collectModulesScript: '${fakeCollect.absolutePath.esc()}',
modulesPaths : ['node_modules'],
skipCollectModules : $skipCollectModules,
]
android {
namespace 'io.sentry.fixture'
compileSdk 34
defaultConfig {
minSdk 21
versionCode 1
versionName '1.0'
}
$additionalBuildTypesBlock
}
$bundleTaskBlock
$additionalTasksBlock
apply from: '${scriptPath.esc()}'
""".trimIndent(),
)

File(projectDir, "local.properties").writeText("sdk.dir=${sdkDir.esc()}")

val manifestDir = File(projectDir, "src/main")
manifestDir.mkdirs()
File(manifestDir, "AndroidManifest.xml").writeText("<manifest />")
}

private fun String.esc(): String = replace("\\", "\\\\")

private fun runner(vararg args: String) =
GradleRunner
.create()
.withProjectDir(projectDir)
.withArguments(*args, "--stacktrace")
// Scrub host `SENTRY_*` vars for determinism, then disable the sourcemap-upload finalizer of
// the bundle task: it shells out to sentry-cli scripts we don't ship in the fixture and is
// unrelated to what these tests cover.
.withEnvironment(
System.getenv().filterKeys { !it.startsWith("SENTRY_") } +
mapOf("SENTRY_DISABLE_AUTO_UPLOAD" to "true"),
).forwardOutput()

private fun run(vararg args: String) = runner(*args).build()

@Test
fun `modules json is generated into build folder and never into src assets`() {
writeFixture()

val result = run(modulesTaskPath)

assertEquals(TaskOutcome.SUCCESS, result.task(modulesTaskPath)?.outcome)
val generated = generatedModules()
assertTrue("modules.json should be generated under build/", generated.isNotEmpty())
assertEquals("""{"fake-module":"1.0.0"}""", generated.first().readText())
assertFalse("modules.json must never be written into src/main/assets", srcAssetsModules().exists())
}

@Test
fun `second run is up-to-date`() {
writeFixture()

assertEquals(TaskOutcome.SUCCESS, run(modulesTaskPath).task(modulesTaskPath)?.outcome)
assertEquals(TaskOutcome.UP_TO_DATE, run(modulesTaskPath).task(modulesTaskPath)?.outcome)
}

@Test
fun `skipCollectModules leaves output empty`() {
writeFixture(skipCollectModules = true)

val result = run(modulesTaskPath)

assertEquals(TaskOutcome.SUCCESS, result.task(modulesTaskPath)?.outcome)
assertTrue("no modules.json when collection is skipped", generatedModules().isEmpty())
}

@Test
fun `missing source map leaves output empty without failing`() {
writeFixture(produceSourcemap = false)

val result = run(modulesTaskPath)

assertEquals(TaskOutcome.SUCCESS, result.task(modulesTaskPath)?.outcome)
assertTrue("no modules.json when source map is absent", generatedModules().isEmpty())
}

/**
* Regression for the lint-dependency *scoping*: the `release` variant's modules task must be wired
* into `release`'s lint tasks only, never into a longer build type whose capitalized name contains
* `Release` (here `qaRelease`). A loose `it.name.contains("Release")` substring match would make
* `lintQaRelease` — and the non-`lint*`-prefixed `updateLintBaselineQaRelease` — depend on the
* `release` modules task. Only `release` has a bundle task here, so the `release` modules task is the
* only `_SentryCollectModules` task that exists; if it shows up in a `qaRelease` lint task's graph,
* the scoping regressed.
*
* The `lintRelease` check is a non-vacuity control: with no wiring at all the modules task is absent
* from every lint graph in this fixture (lint depends on the modules task only through the explicit
* wiring, not via merged-assets), so the negative assertions below would pass trivially without it.
*/
@Test
fun `lint task of a longer variant does not depend on a shorter variant's modules task`() {
writeFixture(
additionalBuildTypesBlock =
"""
buildTypes {
qaRelease { initWith release }
}
""".trimIndent(),
)

// Non-vacuity control: release's own lint task IS wired to the release modules task.
val releaseGraph = runner("lintRelease", "--dry-run").build().output
assertTrue(
"lintRelease should depend on the release modules task",
releaseGraph.contains(modulesTaskPath),
)

// Regression assertions: qaRelease's lint tasks must NOT pull in the release variant's modules
// task — neither the plain lint task nor the non-`lint*`-prefixed baseline update.
val qaReleaseGraph = runner("lintQaRelease", "updateLintBaselineQaRelease", "--dry-run").build().output
assertFalse(
"qaRelease lint tasks must not depend on the release variant's modules task",
qaReleaseGraph.contains(modulesTaskPath),
)
}

/**
* `ktlint*` tasks (from the ktlint Gradle plugin, if an app applies it) carry a lowercase `lint`
* mid-name plus the variant name, but are NOT AGP lint tasks. They must not be made to depend on the
* modules task — that would pull the JS bundler into a Kotlin-style check. The matcher keys on the
* `Lint` camelCase word boundary (start-of-name `lint` verb or capital-L `Lint` segment), which a
* case-insensitive `lint` substring would miss, so `ktlintReleaseCheck` is excluded.
*/
@Test
fun `ktlint tasks are not wired to the modules task`() {
writeFixture(additionalTasksBlock = """tasks.register("ktlintReleaseCheck")""")

// Non-vacuity control: an AGP lint task IS wired, proving the fixture reaches the matcher.
assertTrue(
"lintRelease should depend on the release modules task",
runner("lintRelease", "--dry-run").build().output.contains(modulesTaskPath),
)

assertFalse(
"ktlintReleaseCheck must not depend on the release modules task",
runner("ktlintReleaseCheck", "--dry-run").build().output.contains(modulesTaskPath),
)
}

/**
* Regression for the up-to-date *fingerprint*: `modules.json` must survive the upload flow's
* "clean up extra sourcemap" delete. The sourcemap is a transient artifact; the real bundle task
* doesn't track it as an output, so once deleted it is not regenerated by an up-to-date bundle. If
* the modules task fingerprinted the (now-deleted) sourcemap, the next build would treat its input
* as changed, re-run, find no sourcemap, and package an EMPTY generated assets dir — silently
* dropping module metadata from the release APK/AAB. Fingerprinting the stable bundle instead keeps
* the task UP-TO-DATE across the deletion, so the already-generated `modules.json` is preserved.
*
* The fixture stands in for the cleanup by deleting the sourcemap between builds, with a bundle
* task that declares only the bundle (not the sourcemap) as its output so it stays UP-TO-DATE and
* never regenerates the deleted map.
*/
@Test
fun `modules json survives source map deletion when the bundle is unchanged`() {
writeFixture(bundleDeclaresOutputs = true)

val first = run(modulesTaskPath)
assertEquals(TaskOutcome.SUCCESS, first.task(modulesTaskPath)?.outcome)
assertEquals("""{"fake-module":"1.0.0"}""", generatedModules().single().readText())

// Simulate the upload cleanup (`delete(sourcemapOutput)`), then rebuild without touching the JS.
assertTrue("source map should exist after the first build", sourcemapFile().delete())

val second = run(modulesTaskPath)
assertEquals(
"modules task must stay up-to-date across a source map deletion (bundle unchanged)",
TaskOutcome.UP_TO_DATE,
second.task(modulesTaskPath)?.outcome,
)
assertEquals(
"modules.json must be preserved, not emptied, after the source map is cleaned up",
"""{"fake-module":"1.0.0"}""",
generatedModules().single().readText(),
)
}
}
Loading
Loading