@callstack/react-native-brownfield 5.1.0 → 5.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeBrownfield.kt +22 -4
- package/android/src/main/java/com/callstack/reactnativebrownfield/ReactNativeFragment.kt +3 -1
- package/gradle-plugin/brownfield/build.gradle.kts +7 -0
- package/gradle-plugin/brownfield/gradle/libs.versions.toml +2 -0
- package/gradle-plugin/brownfield/gradle.properties +1 -1
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscoverer.kt +96 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelper.kt +10 -214
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/expo/utils/BrownfieldPublishingInfo.kt +7 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/plugin/RNBrownfieldPlugin.kt +92 -1
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/Constants.kt +12 -1
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils/BrownfieldPrimitives.kt → shared/DependencyInfo.kt} +3 -8
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/DependencyPublishability.kt +22 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/GradleDependencyCollector.kt +44 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjector.kt +181 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFiltering.kt +17 -0
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/{expo/utils → shared}/VersionMediatingDependencySet.kt +1 -1
- package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt +11 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/artifacts/RncTransitiveDependencyDiscovererTest.kt +171 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/expo/ExpoPublishingHelperGradleFallbackTest.kt +47 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/plugin/LegacyConsumerConfigurationTest.kt +110 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/DependencyPublishabilityTest.kt +67 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/PublishingMetadataInjectorTest.kt +222 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/TransitiveDependencyFilteringTest.kt +37 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/shared/VersionMediatingDependencySetTest.kt +42 -0
- package/gradle-plugin/brownfield/src/test/kotlin/com/callstack/react/brownfield/utils/ExtensionTest.kt +11 -0
- package/ios/BrownfieldSurfaceRegistry.swift +93 -0
- package/ios/Views/ReactNativeView.swift +34 -5
- package/ios/Views/ReactNativeViewController.swift +120 -1
- package/lib/commonjs/expo-config-plugin/android/utils/constants.js +1 -1
- package/lib/module/expo-config-plugin/android/utils/constants.js +1 -1
- package/lib/typescript/commonjs/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
- package/lib/typescript/module/src/expo-config-plugin/android/utils/constants.d.ts +2 -2
- package/package.json +2 -2
- package/src/expo-config-plugin/android/utils/constants.ts +1 -1
|
@@ -1,10 +1,4 @@
|
|
|
1
|
-
package com.callstack.react.brownfield.
|
|
2
|
-
|
|
3
|
-
data class BrownfieldPublishingInfo(
|
|
4
|
-
val groupId: String,
|
|
5
|
-
val artifactId: String,
|
|
6
|
-
val version: String,
|
|
7
|
-
)
|
|
1
|
+
package com.callstack.react.brownfield.shared
|
|
8
2
|
|
|
9
3
|
data class DependencyInfo(
|
|
10
4
|
val groupId: String,
|
|
@@ -18,12 +12,13 @@ data class DependencyInfo(
|
|
|
18
12
|
groupId: String,
|
|
19
13
|
artifactId: String,
|
|
20
14
|
version: String?,
|
|
15
|
+
scope: String = "compile",
|
|
21
16
|
): DependencyInfo {
|
|
22
17
|
return DependencyInfo(
|
|
23
18
|
groupId = groupId,
|
|
24
19
|
artifactId = artifactId,
|
|
25
20
|
version = version,
|
|
26
|
-
scope =
|
|
21
|
+
scope = scope,
|
|
27
22
|
optional = false,
|
|
28
23
|
)
|
|
29
24
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.shared
|
|
2
|
+
|
|
3
|
+
/** Matches Maven/Gradle version-range syntax, e.g. `[1.0,2.0)`, `(,2.0]`, `[1.0,)`. */
|
|
4
|
+
private val VERSION_RANGE_REGEX = Regex("^[\\[(].*,.*[\\])]$")
|
|
5
|
+
|
|
6
|
+
/** Reserved Gradle/Maven dynamic-version keywords that resolve to whatever is newest at build time. */
|
|
7
|
+
private val DYNAMIC_VERSION_KEYWORDS = setOf("latest.release", "latest.integration", "latest", "release")
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Whether [dependency] is safe to publish as-is. Rejects dynamic versions (`+`, `latest.*`, ranges)
|
|
11
|
+
* and blank ones: the former resolve non-reproducibly for consumers, the latter emit a
|
|
12
|
+
* `<dependency>` with no version at all.
|
|
13
|
+
*/
|
|
14
|
+
fun isPublishableCoordinate(dependency: DependencyInfo): Boolean {
|
|
15
|
+
val version = dependency.version
|
|
16
|
+
if (version.isNullOrBlank()) return false
|
|
17
|
+
val trimmed = version.trim()
|
|
18
|
+
if (trimmed.contains("+")) return false
|
|
19
|
+
if (trimmed.lowercase() in DYNAMIC_VERSION_KEYWORDS) return false
|
|
20
|
+
if (VERSION_RANGE_REGEX.matches(trimmed)) return false
|
|
21
|
+
return true
|
|
22
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.shared
|
|
2
|
+
|
|
3
|
+
import org.gradle.api.Project
|
|
4
|
+
import org.gradle.api.internal.artifacts.dependencies.DefaultProjectDependency
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Configurations inspected when discovering a project's direct external dependencies.
|
|
8
|
+
*
|
|
9
|
+
* Not flavor-aware — `releaseImplementation` and friends are missed. Fine for Expo packages, which
|
|
10
|
+
* don't use them; extend if an RNC-CLI native module turns out to.
|
|
11
|
+
*/
|
|
12
|
+
val TRANSITIVE_DEPENDENCY_CONFIG_NAMES = listOf("implementation", "api", "runtimeOnly")
|
|
13
|
+
|
|
14
|
+
/** Maven scope per configuration; `runtimeOnly` maps to `runtime` to keep it off the compile classpath. */
|
|
15
|
+
private val CONFIG_NAME_TO_SCOPE =
|
|
16
|
+
mapOf(
|
|
17
|
+
"implementation" to "compile",
|
|
18
|
+
"api" to "compile",
|
|
19
|
+
"runtimeOnly" to "runtime",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* [project]'s direct external (non-project) dependencies on [TRANSITIVE_DEPENDENCY_CONFIG_NAMES],
|
|
24
|
+
* minus anything [isPublishableCoordinate] rejects. Shared by the Expo fallback and RNC paths so
|
|
25
|
+
* both get the same guarantees.
|
|
26
|
+
*/
|
|
27
|
+
fun collectPublishableGradleDependencies(project: Project): List<DependencyInfo> {
|
|
28
|
+
val result = mutableListOf<DependencyInfo>()
|
|
29
|
+
|
|
30
|
+
TRANSITIVE_DEPENDENCY_CONFIG_NAMES.forEach { configName ->
|
|
31
|
+
val configuration = project.configurations.findByName(configName) ?: return@forEach
|
|
32
|
+
val scope = CONFIG_NAME_TO_SCOPE.getValue(configName)
|
|
33
|
+
|
|
34
|
+
configuration.dependencies.forEach { dependency ->
|
|
35
|
+
if (dependency is DefaultProjectDependency) return@forEach
|
|
36
|
+
val group = dependency.group ?: return@forEach
|
|
37
|
+
|
|
38
|
+
val info = DependencyInfo.fromGradleDep(group, dependency.name, dependency.version, scope)
|
|
39
|
+
if (isPublishableCoordinate(info)) result.add(info)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return result
|
|
44
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.shared
|
|
2
|
+
|
|
3
|
+
import groovy.json.JsonOutput
|
|
4
|
+
import groovy.json.JsonSlurper
|
|
5
|
+
import groovy.util.Node
|
|
6
|
+
import groovy.util.NodeList
|
|
7
|
+
import org.gradle.api.Project
|
|
8
|
+
import org.gradle.api.publish.PublishingExtension
|
|
9
|
+
import org.gradle.api.publish.maven.MavenPublication
|
|
10
|
+
import org.gradle.api.publish.tasks.GenerateModuleMetadata
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Removes every `<dependency>` matching [shouldExclude] from [dependenciesNode], then appends one
|
|
14
|
+
* per entry in [dependencies].
|
|
15
|
+
*
|
|
16
|
+
* Removal must run before appending: [shouldExclude] matches on coordinate alone, so afterwards it
|
|
17
|
+
* couldn't tell a stale superseded entry from the one just appended for it, and would drop both.
|
|
18
|
+
*/
|
|
19
|
+
internal fun mutatePomDependenciesNode(
|
|
20
|
+
dependenciesNode: Node,
|
|
21
|
+
dependencies: VersionMediatingDependencySet,
|
|
22
|
+
shouldExclude: (groupId: String, artifactId: String) -> Boolean,
|
|
23
|
+
) {
|
|
24
|
+
dependenciesNode.children()
|
|
25
|
+
.filterIsInstance<Node>()
|
|
26
|
+
.filter { dependency ->
|
|
27
|
+
val groupId = (dependency["groupId"] as NodeList).text()
|
|
28
|
+
val artifactId = (dependency["artifactId"] as NodeList).text()
|
|
29
|
+
shouldExclude(groupId, artifactId)
|
|
30
|
+
}
|
|
31
|
+
.forEach { dependency ->
|
|
32
|
+
dependenciesNode.remove(dependency)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
dependencies.forEach { dependencyToAdd ->
|
|
36
|
+
val childTags =
|
|
37
|
+
mutableMapOf(
|
|
38
|
+
"groupId" to dependencyToAdd.groupId,
|
|
39
|
+
"artifactId" to dependencyToAdd.artifactId,
|
|
40
|
+
"scope" to dependencyToAdd.scope,
|
|
41
|
+
"optional" to dependencyToAdd.optional.toString(),
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
if (dependencyToAdd.version?.isNotBlank() == true) {
|
|
45
|
+
childTags["version"] = dependencyToAdd.version
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
dependenciesNode.appendNode("dependency").let { newDepNode ->
|
|
49
|
+
childTags.forEach { (tagName, tagValue) ->
|
|
50
|
+
newDepNode.appendNode(tagName, tagValue)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The POM's `<dependencies>` element, created if absent — a publication with no dependencies has
|
|
58
|
+
* none, and `first()` throws on it. Extracted so tests exercise this code rather than
|
|
59
|
+
* reimplementing the fallback and passing regardless of what production does.
|
|
60
|
+
*/
|
|
61
|
+
internal fun resolveOrCreateDependenciesNode(root: Node): Node =
|
|
62
|
+
(root.get("dependencies") as NodeList).firstOrNull() as? Node
|
|
63
|
+
?: root.appendNode("dependencies")
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The `module.json` equivalent of [mutatePomDependenciesNode], applied to every variant's
|
|
67
|
+
* `dependencies` list. Same removal-before-append requirement, for the same reason.
|
|
68
|
+
*/
|
|
69
|
+
internal fun mutateModuleJsonVariants(
|
|
70
|
+
json: Map<*, *>,
|
|
71
|
+
dependencies: VersionMediatingDependencySet,
|
|
72
|
+
shouldExclude: (groupId: String, artifactId: String) -> Boolean,
|
|
73
|
+
) {
|
|
74
|
+
@Suppress("UNCHECKED_CAST")
|
|
75
|
+
(json["variants"] as? List<MutableMap<String, Any>>)?.forEach { variant ->
|
|
76
|
+
(variant["dependencies"] as? MutableList<Map<String, Any>>)?.removeAll {
|
|
77
|
+
val group = it["group"] as String
|
|
78
|
+
val module = it["module"] as String
|
|
79
|
+
shouldExclude(group, module)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
dependencies.forEach { dependencyToAdd ->
|
|
84
|
+
@Suppress("UNCHECKED_CAST")
|
|
85
|
+
(json["variants"] as? List<MutableMap<String, Any>>)?.forEach { variant ->
|
|
86
|
+
// Known gap: every dependency lands in every variant, so runtime-scoped ones are
|
|
87
|
+
// compile-visible to Gradle consumers. A fix must key on provenance, NOT on
|
|
88
|
+
// `scope`, which means different things per path: on the Expo path "runtime" is what
|
|
89
|
+
// Gradle emits for `implementation` (73 of 84 deps across the upstream Expo POMs), on
|
|
90
|
+
// the RNC path it means `runtimeOnly`. Filtering on it would strip react-android and
|
|
91
|
+
// appcompat from Expo consumers. Needs a `fromRuntimeOnlyConfiguration` flag on
|
|
92
|
+
// DependencyInfo, and its own PR.
|
|
93
|
+
(variant["dependencies"] as? MutableList<MutableMap<String, Any>>)?.add(
|
|
94
|
+
mutableMapOf<String, Any>(
|
|
95
|
+
"group" to dependencyToAdd.groupId,
|
|
96
|
+
"module" to dependencyToAdd.artifactId,
|
|
97
|
+
).apply {
|
|
98
|
+
dependencyToAdd.version?.let { version ->
|
|
99
|
+
put("version", mapOf("requires" to version))
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Injects transitive dependencies into the generated Maven POM and Gradle Module Metadata for
|
|
109
|
+
* every `MavenPublication` on [project], removing any existing entry the exclusion predicate
|
|
110
|
+
* matches. Used identically by the Expo and RNC-CLI paths.
|
|
111
|
+
*
|
|
112
|
+
* Hooks are registered eagerly on construction, since Gradle disallows registering tasks from
|
|
113
|
+
* `afterEvaluate`. The dependency set isn't known until then, so [configure] supplies it later;
|
|
114
|
+
* until it's called, injection is disabled and the hooks are no-ops.
|
|
115
|
+
*/
|
|
116
|
+
class PublishingMetadataInjector(private val project: Project) {
|
|
117
|
+
private var dependencies = VersionMediatingDependencySet()
|
|
118
|
+
private var shouldExclude: (groupId: String, artifactId: String) -> Boolean = { _, _ -> false }
|
|
119
|
+
private var enabled = false
|
|
120
|
+
|
|
121
|
+
init {
|
|
122
|
+
registerModuleJsonInjectors()
|
|
123
|
+
registerPomInjector()
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Supplies the dependencies to inject and enables injection on the hooks registered in [init]. */
|
|
127
|
+
fun configure(
|
|
128
|
+
dependencies: VersionMediatingDependencySet,
|
|
129
|
+
shouldExclude: (groupId: String, artifactId: String) -> Boolean,
|
|
130
|
+
) {
|
|
131
|
+
this.dependencies = dependencies
|
|
132
|
+
this.shouldExclude = shouldExclude
|
|
133
|
+
this.enabled = true
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
private fun registerModuleJsonInjectors() {
|
|
137
|
+
// One task, not one per publication registered from a GenerateModuleMetadata
|
|
138
|
+
// configureEach — that crashes with "DefaultTaskContainer#register(String) ... cannot be
|
|
139
|
+
// executed in the current context". Walking each task's outputFile at execution time also
|
|
140
|
+
// avoids hardcoding a publication name. Name is namespaced: see Constants.
|
|
141
|
+
val removeDependenciesFromModuleFileTask =
|
|
142
|
+
project.tasks.register(Constants.MODULE_METADATA_POST_PROCESS_TASK_NAME)
|
|
143
|
+
removeDependenciesFromModuleFileTask.configure { task ->
|
|
144
|
+
task.onlyIf { enabled }
|
|
145
|
+
task.doLast {
|
|
146
|
+
project.tasks.withType(GenerateModuleMetadata::class.java).forEach { metadataTask ->
|
|
147
|
+
val moduleFile = metadataTask.outputFile.get().asFile
|
|
148
|
+
if (!moduleFile.exists()) return@forEach
|
|
149
|
+
|
|
150
|
+
val json = moduleFile.inputStream().use { JsonSlurper().parse(it) as Map<*, *> }
|
|
151
|
+
mutateModuleJsonVariants(json, dependencies, shouldExclude)
|
|
152
|
+
moduleFile.writer().use {
|
|
153
|
+
it.write(JsonOutput.prettyPrint(JsonOutput.toJson(json)))
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Finalizing with an already-registered task is safe here, unlike registering one.
|
|
160
|
+
project.tasks.withType(GenerateModuleMetadata::class.java).configureEach { metadataTask ->
|
|
161
|
+
metadataTask.finalizedBy(removeDependenciesFromModuleFileTask)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
private fun registerPomInjector() {
|
|
166
|
+
project.pluginManager.withPlugin("maven-publish") {
|
|
167
|
+
project.extensions.configure(PublishingExtension::class.java) { publishing ->
|
|
168
|
+
publishing.publications.withType(MavenPublication::class.java)
|
|
169
|
+
.configureEach { pub ->
|
|
170
|
+
pub.pom.withXml {
|
|
171
|
+
if (!enabled) return@withXml
|
|
172
|
+
|
|
173
|
+
val root = it.asNode()
|
|
174
|
+
val dependenciesNode = resolveOrCreateDependenciesNode(root)
|
|
175
|
+
mutatePomDependenciesNode(dependenciesNode, dependencies, shouldExclude)
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.shared
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Drops coordinates matching [hardExcludePredicate] — ones that are embedded in the AAR and so
|
|
5
|
+
* would never resolve from Maven. A discovery path can surface these without knowing it: the RNC
|
|
6
|
+
* discoverer has no Expo awareness, so an embedded module depending on an Expo module coordinate
|
|
7
|
+
* would otherwise be published as an ordinary dependency.
|
|
8
|
+
*
|
|
9
|
+
* [hardExcludePredicate] must never include superseded coordinates — those are meant to be
|
|
10
|
+
* re-injected with the mediated version, and dropping them here removes them entirely.
|
|
11
|
+
*/
|
|
12
|
+
internal fun dropHardExcludedDependencies(
|
|
13
|
+
transitiveDeps: VersionMediatingDependencySet,
|
|
14
|
+
hardExcludePredicate: (groupId: String, artifactId: String) -> Boolean,
|
|
15
|
+
): VersionMediatingDependencySet {
|
|
16
|
+
return transitiveDeps.filter { !hardExcludePredicate(it.groupId, it.artifactId) }
|
|
17
|
+
}
|
package/gradle-plugin/brownfield/src/main/kotlin/com/callstack/react/brownfield/utils/Extension.kt
CHANGED
|
@@ -53,4 +53,15 @@ open class Extension {
|
|
|
53
53
|
* listOf("libdatadog-ndk.so")
|
|
54
54
|
*/
|
|
55
55
|
var ignoreEmbeddedLibs = listOf<String>()
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Whether to publish embedded native modules' third-party dependencies into the generated POM
|
|
59
|
+
* and Gradle Module Metadata, so consuming apps resolve them instead of declaring them by hand.
|
|
60
|
+
*
|
|
61
|
+
* Experimental; behavior may still change. Applies to non-Expo (RNC CLI) projects only — Expo
|
|
62
|
+
* gets this unconditionally. Set to `false` to hand-declare them instead.
|
|
63
|
+
*
|
|
64
|
+
* Default is `true`.
|
|
65
|
+
*/
|
|
66
|
+
var experimentalIncludeTransitiveDependencies = true
|
|
56
67
|
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.artifacts
|
|
2
|
+
|
|
3
|
+
import com.callstack.react.brownfield.shared.DependencyInfo
|
|
4
|
+
import com.callstack.react.brownfield.shared.UnresolvedArtifactInfo
|
|
5
|
+
import org.gradle.testfixtures.ProjectBuilder
|
|
6
|
+
import org.junit.jupiter.api.Test
|
|
7
|
+
import kotlin.test.assertEquals
|
|
8
|
+
import kotlin.test.assertFalse
|
|
9
|
+
import kotlin.test.assertTrue
|
|
10
|
+
|
|
11
|
+
class RncTransitiveDependencyDiscovererTest {
|
|
12
|
+
private fun defaultArtifacts(root: org.gradle.api.Project) =
|
|
13
|
+
listOf(
|
|
14
|
+
UnresolvedArtifactInfo(
|
|
15
|
+
moduleGroup = root.name,
|
|
16
|
+
moduleName = "react-native-fake-module",
|
|
17
|
+
moduleVersion = "unspecified",
|
|
18
|
+
file = null,
|
|
19
|
+
isExpoPublishDependency = false,
|
|
20
|
+
),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
@Test
|
|
24
|
+
fun `a dynamic consumer-declared version never becomes a mediation candidate`() {
|
|
25
|
+
// Module-side versions are validated by collectPublishableGradleDependencies; the
|
|
26
|
+
// consumer side must be validated too, or `1.+` can win mediation and be published.
|
|
27
|
+
val root = ProjectBuilder.builder().build()
|
|
28
|
+
val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
|
|
29
|
+
val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
|
|
30
|
+
|
|
31
|
+
embeddedModule.configurations.create("implementation")
|
|
32
|
+
embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.0.0")
|
|
33
|
+
|
|
34
|
+
consumer.configurations.create("implementation")
|
|
35
|
+
consumer.dependencies.add("implementation", "androidx.appcompat:appcompat:1.+")
|
|
36
|
+
|
|
37
|
+
val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
|
|
38
|
+
|
|
39
|
+
val appcompat = result.dependencies.first { it.groupId == "androidx.appcompat" }
|
|
40
|
+
assertEquals("1.0.0", appcompat.version, "the dynamic consumer version must not win mediation")
|
|
41
|
+
assertFalse(result.dependencies.any { it.version == "1.+" }, "'1.+' must never reach the published set")
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
@Test
|
|
45
|
+
fun `a versionless consumer declaration still supersedes, so the coordinate is not duplicated`() {
|
|
46
|
+
// Declared via BOM/platform, so `.version` is null. A version-only lookup returns null
|
|
47
|
+
// here, never marks the coordinate superseded, and leaves two entries for it in the POM.
|
|
48
|
+
val root = ProjectBuilder.builder().build()
|
|
49
|
+
val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
|
|
50
|
+
val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
|
|
51
|
+
|
|
52
|
+
embeddedModule.configurations.create("implementation")
|
|
53
|
+
embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.7.1")
|
|
54
|
+
|
|
55
|
+
consumer.configurations.create("implementation")
|
|
56
|
+
consumer.dependencies.add("implementation", "androidx.appcompat:appcompat")
|
|
57
|
+
|
|
58
|
+
val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
|
|
59
|
+
|
|
60
|
+
assertTrue(
|
|
61
|
+
result.supersededCoordinates.contains("androidx.appcompat" to "appcompat"),
|
|
62
|
+
"a declared-but-versionless coordinate must still be superseded",
|
|
63
|
+
)
|
|
64
|
+
assertEquals("1.7.1", result.dependencies.first { it.groupId == "androidx.appcompat" }.version)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
@Test
|
|
68
|
+
fun `discovers a module's direct external dependencies, skipping project deps and non-publishable coordinates`() {
|
|
69
|
+
val root = ProjectBuilder.builder().build()
|
|
70
|
+
val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
|
|
71
|
+
val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
|
|
72
|
+
val siblingProject = ProjectBuilder.builder().withParent(root).withName("some-other-module").build()
|
|
73
|
+
|
|
74
|
+
embeddedModule.configurations.create("implementation")
|
|
75
|
+
embeddedModule.configurations.create("api")
|
|
76
|
+
embeddedModule.configurations.create("runtimeOnly")
|
|
77
|
+
|
|
78
|
+
embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.7.1")
|
|
79
|
+
embeddedModule.dependencies.add("implementation", "com.facebook.react:react-native:+")
|
|
80
|
+
embeddedModule.dependencies.add("runtimeOnly", "androidx.annotation:annotation:1.9.1")
|
|
81
|
+
embeddedModule.dependencies.add(
|
|
82
|
+
"implementation",
|
|
83
|
+
embeddedModule.dependencies.project(mapOf("path" to siblingProject.path)),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
consumer.configurations.create("api")
|
|
87
|
+
|
|
88
|
+
val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
|
|
89
|
+
val discovered = result.dependencies
|
|
90
|
+
|
|
91
|
+
assertEquals(2, discovered.size)
|
|
92
|
+
assertTrue(
|
|
93
|
+
discovered.contains(
|
|
94
|
+
DependencyInfo("androidx.appcompat", "appcompat", "1.7.1", "compile", false),
|
|
95
|
+
),
|
|
96
|
+
)
|
|
97
|
+
assertTrue(
|
|
98
|
+
discovered.contains(
|
|
99
|
+
DependencyInfo("androidx.annotation", "annotation", "1.9.1", "runtime", false),
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
assertFalse(discovered.contains(DependencyInfo("com.facebook.react", "react-native", "+", "compile", false)))
|
|
103
|
+
assertFalse(discovered.contains(DependencyInfo(root.name, "some-other-module", "unspecified", "compile", false)))
|
|
104
|
+
assertTrue(result.supersededCoordinates.isEmpty())
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
@Test
|
|
108
|
+
fun `publishes a runtimeOnly dependency under Maven runtime scope, not compile`() {
|
|
109
|
+
val root = ProjectBuilder.builder().build()
|
|
110
|
+
val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
|
|
111
|
+
val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
|
|
112
|
+
|
|
113
|
+
embeddedModule.configurations.create("implementation")
|
|
114
|
+
embeddedModule.configurations.create("api")
|
|
115
|
+
embeddedModule.configurations.create("runtimeOnly")
|
|
116
|
+
embeddedModule.dependencies.add("runtimeOnly", "androidx.annotation:annotation:1.9.1")
|
|
117
|
+
|
|
118
|
+
val discovered = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root)).dependencies
|
|
119
|
+
|
|
120
|
+
val annotationDep = discovered.first { it.groupId == "androidx.annotation" }
|
|
121
|
+
assertEquals("runtime", annotationDep.scope)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@Test
|
|
125
|
+
fun `a module's higher version requirement wins over, and supersedes, the consumer's own lower declaration`() {
|
|
126
|
+
val root = ProjectBuilder.builder().build()
|
|
127
|
+
val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
|
|
128
|
+
val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
|
|
129
|
+
|
|
130
|
+
embeddedModule.configurations.create("implementation")
|
|
131
|
+
embeddedModule.configurations.create("api")
|
|
132
|
+
embeddedModule.configurations.create("runtimeOnly")
|
|
133
|
+
embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.7.1")
|
|
134
|
+
|
|
135
|
+
consumer.configurations.create("api")
|
|
136
|
+
consumer.dependencies.add("api", "androidx.appcompat:appcompat:1.6.0")
|
|
137
|
+
|
|
138
|
+
val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
|
|
139
|
+
|
|
140
|
+
val appcompatDep = result.dependencies.first { it.groupId == "androidx.appcompat" }
|
|
141
|
+
assertEquals("1.7.1", appcompatDep.version, "the module's higher requirement must win mediation")
|
|
142
|
+
assertTrue(
|
|
143
|
+
result.supersededCoordinates.contains("androidx.appcompat" to "appcompat"),
|
|
144
|
+
"the consumer's stale lower-version entry must be marked for replacement",
|
|
145
|
+
)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
@Test
|
|
149
|
+
fun `does not regress or supersede when the consumer's own version already satisfies the module`() {
|
|
150
|
+
val root = ProjectBuilder.builder().build()
|
|
151
|
+
val consumer = ProjectBuilder.builder().withParent(root).withName("BrownfieldLib").build()
|
|
152
|
+
val embeddedModule = ProjectBuilder.builder().withParent(root).withName("react-native-fake-module").build()
|
|
153
|
+
|
|
154
|
+
embeddedModule.configurations.create("implementation")
|
|
155
|
+
embeddedModule.configurations.create("api")
|
|
156
|
+
embeddedModule.configurations.create("runtimeOnly")
|
|
157
|
+
embeddedModule.dependencies.add("implementation", "androidx.appcompat:appcompat:1.6.0")
|
|
158
|
+
|
|
159
|
+
consumer.configurations.create("api")
|
|
160
|
+
consumer.dependencies.add("api", "androidx.appcompat:appcompat:1.7.1")
|
|
161
|
+
|
|
162
|
+
val result = RncTransitiveDependencyDiscoverer(consumer).discover(defaultArtifacts(root))
|
|
163
|
+
|
|
164
|
+
val appcompatDep = result.dependencies.first { it.groupId == "androidx.appcompat" }
|
|
165
|
+
assertEquals("1.7.1", appcompatDep.version, "the consumer's already-higher version must be preserved, not downgraded")
|
|
166
|
+
assertTrue(
|
|
167
|
+
result.supersededCoordinates.contains("androidx.appcompat" to "appcompat"),
|
|
168
|
+
"still marked superseded so the injector re-declares it at the mediated (unchanged) version instead of duplicating it",
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.expo
|
|
2
|
+
|
|
3
|
+
import com.callstack.react.brownfield.shared.VersionMediatingDependencySet
|
|
4
|
+
import org.gradle.api.Project
|
|
5
|
+
import org.gradle.testfixtures.ProjectBuilder
|
|
6
|
+
import org.junit.jupiter.api.Test
|
|
7
|
+
import kotlin.test.assertEquals
|
|
8
|
+
import kotlin.test.assertTrue
|
|
9
|
+
|
|
10
|
+
class ExpoPublishingHelperGradleFallbackTest {
|
|
11
|
+
@Test
|
|
12
|
+
fun `Gradle-fallback discovery picks up runtimeOnly dependencies`() {
|
|
13
|
+
val root = ProjectBuilder.builder().build()
|
|
14
|
+
val expoPkgProject = ProjectBuilder.builder().withParent(root).withName("expo-fake-module").build()
|
|
15
|
+
|
|
16
|
+
expoPkgProject.configurations.create("implementation")
|
|
17
|
+
expoPkgProject.configurations.create("api")
|
|
18
|
+
expoPkgProject.configurations.create("runtimeOnly")
|
|
19
|
+
expoPkgProject.dependencies.add("runtimeOnly", "androidx.annotation:annotation:1.9.1")
|
|
20
|
+
|
|
21
|
+
val helper =
|
|
22
|
+
object : ExpoPublishingHelper(brownfieldAppProject = root) {
|
|
23
|
+
fun exposedAppend(
|
|
24
|
+
pkgProject: Project,
|
|
25
|
+
deps: VersionMediatingDependencySet,
|
|
26
|
+
) {
|
|
27
|
+
appendExpoTransitiveDependenciesFromGradle(pkgProject, deps)
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
val discovered = VersionMediatingDependencySet()
|
|
32
|
+
helper.exposedAppend(expoPkgProject, discovered)
|
|
33
|
+
|
|
34
|
+
// Coordinate-only check: VersionMediatingDependencySet keys on groupId/artifactId, so
|
|
35
|
+
// passing a scope here would be inert and would read as contradicting the scope
|
|
36
|
+
// assertion below.
|
|
37
|
+
assertTrue(
|
|
38
|
+
discovered.any { it.groupId == "androidx.annotation" && it.artifactId == "annotation" },
|
|
39
|
+
"expected the runtimeOnly dependency to be discovered via the Gradle fallback path",
|
|
40
|
+
)
|
|
41
|
+
assertEquals(
|
|
42
|
+
"runtime",
|
|
43
|
+
discovered.first { it.groupId == "androidx.annotation" }.scope,
|
|
44
|
+
"runtimeOnly dependencies should be published under Maven's runtime scope, not compile",
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
package com.callstack.react.brownfield.plugin
|
|
2
|
+
|
|
3
|
+
import com.callstack.react.brownfield.shared.Constants
|
|
4
|
+
import org.gradle.testkit.runner.GradleRunner
|
|
5
|
+
import org.junit.jupiter.api.Assumptions.assumeTrue
|
|
6
|
+
import org.junit.jupiter.api.Test
|
|
7
|
+
import org.junit.jupiter.api.io.TempDir
|
|
8
|
+
import java.io.File
|
|
9
|
+
import kotlin.test.assertFalse
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Guards the pre-migration consumer shape no in-repo app has any more: one that still hand-registers
|
|
13
|
+
* `removeDependenciesFromModuleFile` as the setup docs instruct. If the plugin ever claims that name
|
|
14
|
+
* again, configuration fails outright for every consumer who hasn't migrated.
|
|
15
|
+
*
|
|
16
|
+
* Skipped without an Android SDK, since the plugin requires `com.android.library`.
|
|
17
|
+
*/
|
|
18
|
+
class LegacyConsumerConfigurationTest {
|
|
19
|
+
@Test
|
|
20
|
+
fun `a project still registering the documented legacy task configures successfully`(
|
|
21
|
+
@TempDir projectDir: File,
|
|
22
|
+
) {
|
|
23
|
+
val androidSdk = System.getenv("ANDROID_HOME") ?: System.getenv("ANDROID_SDK_ROOT")
|
|
24
|
+
assumeTrue(
|
|
25
|
+
androidSdk != null && File(androidSdk).isDirectory,
|
|
26
|
+
"no Android SDK available (ANDROID_HOME/ANDROID_SDK_ROOT); skipping AGP-dependent test",
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
writeLegacyConsumerFixture(projectDir, androidSdk!!)
|
|
30
|
+
|
|
31
|
+
val result =
|
|
32
|
+
GradleRunner.create()
|
|
33
|
+
.withProjectDir(projectDir)
|
|
34
|
+
.withPluginClasspath()
|
|
35
|
+
.withArguments("tasks", "--stacktrace")
|
|
36
|
+
.forwardOutput()
|
|
37
|
+
.build()
|
|
38
|
+
|
|
39
|
+
// build() already failed above on any unrelated fixture breakage; this narrows a failure
|
|
40
|
+
// here to the duplicate-task-name case specifically.
|
|
41
|
+
assertFalse(
|
|
42
|
+
result.output.contains("as a task with that name already exists"),
|
|
43
|
+
"build output reports a duplicate task name; the plugin most likely claimed " +
|
|
44
|
+
"'${Constants.LEGACY_CONSUMER_MODULE_METADATA_TASK_NAME}', which the setup docs " +
|
|
45
|
+
"reserve for consumers",
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
private fun writeLegacyConsumerFixture(
|
|
50
|
+
projectDir: File,
|
|
51
|
+
androidSdk: String,
|
|
52
|
+
) {
|
|
53
|
+
File(projectDir, "settings.gradle.kts").writeText(
|
|
54
|
+
"""
|
|
55
|
+
dependencyResolutionManagement {
|
|
56
|
+
repositories {
|
|
57
|
+
google()
|
|
58
|
+
mavenCentral()
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
rootProject.name = "legacy-consumer"
|
|
62
|
+
include(":app")
|
|
63
|
+
include(":brownfieldlib")
|
|
64
|
+
""".trimIndent(),
|
|
65
|
+
)
|
|
66
|
+
File(projectDir, "local.properties").writeText("sdk.dir=$androidSdk\n")
|
|
67
|
+
// ArtifactsResolver looks for an `app` project; an empty stub is enough, this test is about
|
|
68
|
+
// the task container, not discovery.
|
|
69
|
+
File(projectDir, "app").mkdirs()
|
|
70
|
+
File(projectDir, "app/build.gradle.kts").writeText("")
|
|
71
|
+
|
|
72
|
+
// The `tasks.register("removeDependenciesFromModuleFile")` block below is verbatim from
|
|
73
|
+
// the published setup docs. Do not "modernize" it — its whole purpose is to reproduce
|
|
74
|
+
// what a not-yet-migrated consumer actually has in their build script.
|
|
75
|
+
File(projectDir, "brownfieldlib").mkdirs()
|
|
76
|
+
File(projectDir, "brownfieldlib/build.gradle.kts").writeText(
|
|
77
|
+
"""
|
|
78
|
+
import groovy.json.JsonOutput
|
|
79
|
+
import groovy.json.JsonSlurper
|
|
80
|
+
|
|
81
|
+
plugins {
|
|
82
|
+
id("com.android.library")
|
|
83
|
+
id("com.callstack.react.brownfield")
|
|
84
|
+
`maven-publish`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
android {
|
|
88
|
+
namespace = "com.example.legacyconsumer"
|
|
89
|
+
compileSdk = 34
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
val moduleBuildDir: Directory = layout.buildDirectory.get()
|
|
93
|
+
|
|
94
|
+
tasks.register("removeDependenciesFromModuleFile") {
|
|
95
|
+
doLast {
|
|
96
|
+
file("${'$'}moduleBuildDir/publications/mavenAar/module.json").run {
|
|
97
|
+
val json = inputStream().use { JsonSlurper().parse(it) as Map<String, Any> }
|
|
98
|
+
(json["variants"] as? List<MutableMap<String, Any>>)?.forEach { variant ->
|
|
99
|
+
(variant["dependencies"] as? MutableList<Map<String, Any>>)?.removeAll {
|
|
100
|
+
it["group"] == rootProject.name
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
writer().use { it.write(JsonOutput.prettyPrint(JsonOutput.toJson(json))) }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
""".trimIndent(),
|
|
108
|
+
)
|
|
109
|
+
}
|
|
110
|
+
}
|