diff --git a/packages/react-native-gradle-plugin/settings-plugin/src/main/kotlin/com/facebook/react/ReactSettingsExtension.kt b/packages/react-native-gradle-plugin/settings-plugin/src/main/kotlin/com/facebook/react/ReactSettingsExtension.kt index 8f2f4476d6d..be26a223379 100644 --- a/packages/react-native-gradle-plugin/settings-plugin/src/main/kotlin/com/facebook/react/ReactSettingsExtension.kt +++ b/packages/react-native-gradle-plugin/settings-plugin/src/main/kotlin/com/facebook/react/ReactSettingsExtension.kt @@ -7,6 +7,7 @@ package com.facebook.react +import com.facebook.react.model.ModelAutolinkingConfigJson import com.facebook.react.utils.JsonUtils import com.facebook.react.utils.windowsAwareCommandLine import java.io.File @@ -53,30 +54,22 @@ abstract class ReactSettingsExtension @Inject constructor(val settings: Settings .files("yarn.lock", "package-lock.json", "package.json", "react-native.config.js") ) { outputFile.parentFile.mkdirs() - val lockFilesChanged = checkAndUpdateLockfiles(lockFiles, outputFolder) - if (lockFilesChanged || outputFile.exists().not() || outputFile.length() != 0L) { - val process = - ProcessBuilder(command) - .directory(workingDirectory) - .redirectOutput(ProcessBuilder.Redirect.to(outputFile)) - .redirectError(ProcessBuilder.Redirect.INHERIT) - .start() - val finished = process.waitFor(5, TimeUnit.MINUTES) - if (!finished || (process.exitValue() != 0)) { - val prefixCommand = - "ERROR: autolinkLibrariesFromCommand: process ${command.joinToString(" ")}" - val message = - if (!finished) "${prefixCommand} timed out" - else "${prefixCommand} exited with error code: ${process.exitValue()}" - val logger = Logging.getLogger("ReactSettingsExtension") - logger.error(message) - if (outputFile.length() != 0L) { - logger.error(outputFile.readText().substring(0, 1024)) + + val updateConfig = + object : GenerateConfig { + private val pb = + ProcessBuilder(command) + .directory(workingDirectory) + .redirectOutput(ProcessBuilder.Redirect.to(outputFile)) + .redirectError(ProcessBuilder.Redirect.INHERIT) + + override fun command(): List = pb.command() + + override fun start(): Process = pb.start() } - outputFile.delete() - throw GradleException(message) - } - } + + checkAndUpdateCache(updateConfig, outputFile, outputFolder, lockFiles) + linkLibraries(getLibrariesToAutolink(outputFile)) } @@ -107,9 +100,73 @@ abstract class ReactSettingsExtension @Inject constructor(val settings: Settings } } + internal interface GenerateConfig { + fun command(): List + + fun start(): Process + } + companion object { private val md = MessageDigest.getInstance("SHA-256") + /** + * Determine if our cache is out-of-date + * + * @param cacheJsonConfig Our current cached autolinking.json config, which may exist + * @param cacheFolder The folder we store our cached SHAs and config + * @param lockFiles The [FileCollection] of the lockfiles to check. + * @return `true` if the cache needs to be rebuilt, `false` otherwise + */ + internal fun isCacheDirty( + cacheJsonConfig: File, + cacheFolder: File, + lockFiles: FileCollection, + ): Boolean { + if (cacheJsonConfig.exists().not() || cacheJsonConfig.length() == 0L) { + return true + } + val lockFilesChanged = checkAndUpdateLockfiles(lockFiles, cacheFolder) + if (lockFilesChanged) { + return true + } + return isConfigModelInvalid(JsonUtils.fromAutolinkingConfigJson(cacheJsonConfig)) + } + + /** + * Utility function to update the settings cache only if it's entries are dirty + * + * @param updateJsonConfig A [GenerateConfig] to update the project's autolinking config + * @param cacheJsonConfig Our current cached autolinking.json config, which may exist + * @param cacheFolder The folder we store our cached SHAs and config + * @param lockFiles The [FileCollection] of the lockfiles to check. + */ + internal fun checkAndUpdateCache( + updateJsonConfig: GenerateConfig, + cacheJsonConfig: File, + cacheFolder: File, + lockFiles: FileCollection, + ) { + if (isCacheDirty(cacheJsonConfig, cacheFolder, lockFiles)) { + val process = updateJsonConfig.start() + + val finished = process.waitFor(5, TimeUnit.MINUTES) + if (!finished || (process.exitValue() != 0)) { + val command = updateJsonConfig.command().joinToString(" ") + val prefixCommand = "ERROR: autolinkLibrariesFromCommand: process $command" + val message = + if (!finished) "$prefixCommand timed out" + else "$prefixCommand exited with error code: ${process.exitValue()}" + val logger = Logging.getLogger("ReactSettingsExtension") + logger.error(message) + if (cacheJsonConfig.length() != 0L) { + logger.error(cacheJsonConfig.readText().substring(0, 1024)) + } + cacheJsonConfig.delete() + throw GradleException(message) + } + } + } + /** * Utility function to check if the provided lockfiles have been updated or not. This function * will both check and update the lockfiles hashes if necessary. @@ -150,5 +207,8 @@ abstract class ReactSettingsExtension @Inject constructor(val settings: Settings internal fun computeSha256(lockFile: File) = String.format("%032x", BigInteger(1, md.digest(lockFile.readBytes()))) + + internal fun isConfigModelInvalid(model: ModelAutolinkingConfigJson?) = + model?.project?.android?.packageName == null } } diff --git a/packages/react-native-gradle-plugin/settings-plugin/src/test/kotlin/com/facebook/react/ReactSettingsExtensionTest.kt b/packages/react-native-gradle-plugin/settings-plugin/src/test/kotlin/com/facebook/react/ReactSettingsExtensionTest.kt index 3f89e1b935c..7197e242fb2 100644 --- a/packages/react-native-gradle-plugin/settings-plugin/src/test/kotlin/com/facebook/react/ReactSettingsExtensionTest.kt +++ b/packages/react-native-gradle-plugin/settings-plugin/src/test/kotlin/com/facebook/react/ReactSettingsExtensionTest.kt @@ -7,10 +7,12 @@ package com.facebook.react +import com.facebook.react.ReactSettingsExtension.Companion.checkAndUpdateCache import com.facebook.react.ReactSettingsExtension.Companion.checkAndUpdateLockfiles import com.facebook.react.ReactSettingsExtension.Companion.computeSha256 import com.facebook.react.ReactSettingsExtension.Companion.getLibrariesToAutolink import groovy.test.GroovyTestCase.assertEquals +import com.facebook.react.ReactSettingsExtension.GenerateConfig import java.io.File import org.gradle.testfixtures.ProjectBuilder import org.intellij.lang.annotations.Language @@ -236,6 +238,241 @@ class ReactSettingsExtensionTest { File(buildFolder, "package-lock.json.sha").readText()) } + @Test + fun skipUpdateIfConfigInCacheIsValid() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val buildFolder = tempFolder.newFolder("build") + val generatedFolder = tempFolder.newFolder("build", "generated") + val outputFile = + File(generatedFolder, "autolinking.json").apply { + writeText( + """ + { + "root": "/", + "reactNativePath": "/node_modules/react-native", + "reactNativeVersion": "0.75", + "dependencies": {}, + "healthChecks": [], + "platforms": { + "ios": {}, + "android": {} + }, + "assets": [], + "project": { + "ios": {}, + "android": { + "sourceDir": "/", + "appName": "app", + "packageName": "com.TestApp", + "applicationId": "com.TestApp", + "mainActivity": ".MainActivity", + "assets": [] + } + } + } + """ + .trimIndent()) + } + tempFolder.newFile("yarn.lock").apply { writeText("I'm a lockfile") } + val lockfileCollection = project.files("yarn.lock") + + // Prebuild the shas with the invalid empty autolinking.json + checkAndUpdateLockfiles(lockfileCollection, buildFolder) + + val monitoredUpdateConfig = createMonitoredUpdateConfig() + + checkAndUpdateCache(monitoredUpdateConfig, outputFile, buildFolder, lockfileCollection) + + // The autolinking.json file is valid, SHA's are untouched therefore config should NOT be + // refreshed + assertThat(monitoredUpdateConfig.run).isFalse() + } + + @Test + fun checkAndUpdateConfigIfEmpty() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val buildFolder = tempFolder.newFolder("build") + val generatedFolder = tempFolder.newFolder("build", "generated") + val outputFile = File(generatedFolder, "autolinking.json").apply { writeText("") } + tempFolder.newFile("yarn.lock").apply { writeText("I'm a lockfile") } + val lockfileCollection = project.files("yarn.lock") + + // Prebuild the shas with the invalid empty autolinking.json + checkAndUpdateLockfiles(lockfileCollection, buildFolder) + + val monitoredUpdateConfig = createMonitoredUpdateConfig() + + checkAndUpdateCache(monitoredUpdateConfig, outputFile, buildFolder, lockfileCollection) + + // The autolinking.json file is invalid and should be refreshed + assertThat(monitoredUpdateConfig.run).isTrue() + } + + @Test + fun checkAndUpdateConfigIfCachedConfigInvalid() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val buildFolder = tempFolder.newFolder("build") + val generatedFolder = tempFolder.newFolder("build", "generated") + val outputFile = + File(generatedFolder, "autolinking.json").apply { + writeText( + """ + { + "project": { + "ios": {}, + "android": {} + } + } + """ + .trimIndent()) + } + tempFolder.newFile("yarn.lock").apply { writeText("I'm a lockfile") } + val lockfileCollection = project.files("yarn.lock") + + // Prebuild the shas with the invalid empty autolinking.json + checkAndUpdateLockfiles(lockfileCollection, buildFolder) + + val monitoredUpdateConfig = createMonitoredUpdateConfig() + + checkAndUpdateCache(monitoredUpdateConfig, outputFile, buildFolder, lockfileCollection) + + // The autolinking.json file is invalid and should be refreshed + assertThat(monitoredUpdateConfig.run).isTrue() + } + + @Test + fun isCacheDirty_withMissingAutolinkingFile_returnsTrue() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val buildFolder = + tempFolder.newFolder("build").apply { + File(this, "yarn.lock.sha") + .writeText("76046b72442ee7eb130627e56c3db7c9907eef4913b17ad130335edc0eb702a8") + } + tempFolder.newFile("yarn.lock").apply { writeText("I'm a lockfile") } + val lockfiles = project.files("yarn.lock") + val emptyConfigFile = File(tempFolder.newFolder("build", "autolinking"), "autolinking.json") + + assertThat(ReactSettingsExtension.isCacheDirty(emptyConfigFile, buildFolder, lockfiles)) + .isTrue() + } + + @Test + fun isCacheDirty_withInvalidAutolinkingFile_returnsTrue() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val buildFolder = + tempFolder.newFolder("build").apply { + File(this, "yarn.lock.sha") + .writeText("76046b72442ee7eb130627e56c3db7c9907eef4913b17ad130335edc0eb702a8") + } + tempFolder.newFile("yarn.lock").apply { writeText("I'm a lockfile") } + val lockfiles = project.files("yarn.lock") + val invalidConfigFile = + createJsonFile( + """ + {} + """ + .trimIndent()) + + assertThat(ReactSettingsExtension.isCacheDirty(invalidConfigFile, buildFolder, lockfiles)) + .isTrue() + } + + @Test + fun isCacheDirty_withMissingDependenciesInJson_returnsFalse() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val buildFolder = + tempFolder.newFolder("build").apply { + File(this, "yarn.lock.sha") + .writeText("76046b72442ee7eb130627e56c3db7c9907eef4913b17ad130335edc0eb702a8") + } + tempFolder.newFile("yarn.lock").apply { writeText("I'm a lockfile") } + val lockfiles = project.files("yarn.lock") + val invalidConfigFile = + createJsonFile( + """ + { + "reactNativeVersion": "1000.0.0" + } + """ + .trimIndent()) + + assertThat(ReactSettingsExtension.isCacheDirty(invalidConfigFile, buildFolder, lockfiles)) + .isTrue() + } + + @Test + fun isCacheDirty_withExistingEmptyDependenciesInJson_returnsTrue() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val buildFolder = + tempFolder.newFolder("build").apply { + File(this, "yarn.lock.sha") + .writeText("76046b72442ee7eb130627e56c3db7c9907eef4913b17ad130335edc0eb702a8") + } + tempFolder.newFile("yarn.lock").apply { writeText("I'm a lockfile") } + val lockfiles = project.files("yarn.lock") + val invalidConfigFile = + createJsonFile( + """ + { + "reactNativeVersion": "1000.0.0", + "dependencies": {} + } + """ + .trimIndent()) + + assertThat(ReactSettingsExtension.isCacheDirty(invalidConfigFile, buildFolder, lockfiles)) + .isTrue() + } + + @Test + fun isCacheDirty_withExistingDependenciesInJson_returnsTrue() { + val project = ProjectBuilder.builder().withProjectDir(tempFolder.root).build() + val buildFolder = + tempFolder.newFolder("build").apply { + File(this, "yarn.lock.sha") + .writeText("76046b72442ee7eb130627e56c3db7c9907eef4913b17ad130335edc0eb702a8") + } + tempFolder.newFile("yarn.lock").apply { writeText("I'm a lockfile") } + val lockfiles = project.files("yarn.lock") + val invalidConfigFile = + createJsonFile( + """ + { + "reactNativeVersion": "1000.0.0", + "dependencies": { + "@react-native/oss-library-example": { + "root": "./node_modules/@react-native/oss-library-example", + "name": "@react-native/oss-library-example", + "platforms": { + "ios": { + "podspecPath": "./node_modules/@react-native/oss-library-example/OSSLibraryExample.podspec", + "version": "0.0.1", + "configurations": [], + "scriptPhases": [] + } + } + } + } + } + """ + .trimIndent()) + + assertThat(ReactSettingsExtension.isCacheDirty(invalidConfigFile, buildFolder, lockfiles)) + .isTrue() + } + private fun createJsonFile(@Language("JSON") input: String) = tempFolder.newFile().apply { writeText(input) } + + private fun createMonitoredUpdateConfig() = + object : GenerateConfig { + var run = false + + override fun start(): Process { + run = true + return ProcessBuilder("true").start() + } + + override fun command(): List = listOf("true") + } }