Skip to content

Commit

Permalink
Merge branch 'main' into z/ew
Browse files Browse the repository at this point in the history
  • Loading branch information
ZacSweers committed Dec 10, 2024
2 parents 93613ab + 3644970 commit a29284a
Show file tree
Hide file tree
Showing 9 changed files with 351 additions and 299 deletions.
6 changes: 0 additions & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,6 @@ jobs:

cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}

# Limit the size of the cache entry.
# These directories contain instrumented/transformed dependency jars which can be reconstructed relatively quickly.
gradle-home-cache-excludes: |
caches/jars-9
caches/transforms-3
- name: Build and run tests
id: gradle
timeout-minutes: 10
Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ Changelog
**Unreleased**
--------------

- Add a `foundry.android.test.compressWithLegacyPackaging` flag to compress androidTest APKs with legacy packaging.
- Support emulator.wtf for `androidTest()`. This feature is gated by the `foundry.emulatorwtf.enable` feature flag.

0.22.6
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ nullawayGradle = "2.1.0"
okhttp = "5.0.0-alpha.12"
okio = "3.9.1"
retrofit = "2.11.0"
roborazzi = "1.32.2"
roborazzi = "1.36.0"
slack-lint = "0.8.2"
sortDependencies = "0.13"
spotless = "7.0.0.BETA4"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -473,6 +473,26 @@ internal constructor(
public val useOrchestrator: Provider<Boolean>
get() = resolver.booleanProvider("foundry.android.test.orchestrator", false)

/**
* Flag for compressing androidTest APks with legacy packaging.
*
* See:
* - https://issuetracker.google.com/issues/259832799
* - https://developer.android.com/reference/tools/gradle-api/7.1/com/android/build/api/dsl/DexPackagingOptions#useLegacyPackaging:kotlin.Boolean
*/
public val compressAndroidTestApksWithLegacyPackaging: Provider<Boolean>
get() = resolver.booleanProvider("foundry.android.test.compressWithLegacyPackaging", false)

/**
* Flag for minifying androidTest APks with R8. This just tree shakes.
*
* See:
* - https://issuetracker.google.com/issues/259832799
* - https://developer.android.com/reference/tools/gradle-api/7.1/com/android/build/api/dsl/DexPackagingOptions#useLegacyPackaging:kotlin.Boolean
*/
public val minifyAndroidTestApks: Provider<Boolean>
get() = resolver.booleanProvider("foundry.android.test.minifyEnabled", false)

/**
* Location for robolectric-core to be referenced by app. Temporary till we have a better solution
* for "always add these" type of deps.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -510,8 +510,15 @@ internal class StandardProjectConfigurations(
if (isAndroidTestEnabled) {
if (!excluded && isAffectedProject) {
// Aggregate test apks. In Fladle we aggregate test APKs, in emulator.wtf we aggregate
// to their
// root project dep
// to their root project dep
if (isLibraryVariant) {
val libraryVariant = variant as LibraryVariant
libraryVariant.androidTest?.apply {
packaging.dex.useLegacyPackaging.set(
foundryProperties.compressAndroidTestApksWithLegacyPackaging
)
}
}
if (
foundryProperties.enableEmulatorWtfForAndroidTest &&
pluginManager.hasPlugin("wtf.emulator.gradle")
Expand All @@ -536,14 +543,11 @@ internal class StandardProjectConfigurations(
)
.publishWith(skippyAndroidTestProjectPublisher)
if (isLibraryVariant) {
(variant as LibraryVariant)
.androidTest
?.artifacts
?.get(SingleArtifact.APK)
?.let { apkArtifactsDir ->
// Wire this up to the aggregator. No need for an intermediate task here.
androidTestApksPublisher.publishDirs(apkArtifactsDir)
}
val libraryVariant = variant as LibraryVariant
libraryVariant.androidTest?.apply {
// Wire this up to the aggregator. No need for an intermediate task here.
androidTestApksPublisher.publishDirs(artifacts.get(SingleArtifact.APK))
}
}
}
} else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,36 +15,18 @@
*/
package foundry.gradle.topography

import com.github.ajalt.mordant.markdown.Markdown
import com.github.ajalt.mordant.rendering.AnsiLevel
import com.github.ajalt.mordant.terminal.Terminal
import foundry.cli.walkEachFile
import foundry.common.json.JsonTools
import foundry.gradle.FoundryExtension
import foundry.gradle.FoundryProperties
import foundry.gradle.artifacts.FoundryArtifact
import foundry.gradle.artifacts.Publisher
import foundry.gradle.artifacts.Resolver
import foundry.gradle.avoidance.SkippyArtifacts
import foundry.gradle.capitalizeUS
import foundry.gradle.properties.setDisallowChanges
import foundry.gradle.register
import foundry.gradle.serviceOf
import foundry.gradle.tasks.SimpleFileProducerTask
import foundry.gradle.tasks.SimpleFilesConsumerTask
import foundry.gradle.tasks.mustRunAfterSourceGeneratingTasks
import foundry.gradle.tasks.publish
import foundry.gradle.util.toJson
import java.nio.file.Path
import kotlin.io.path.ExperimentalPathApi
import kotlin.io.path.extension
import kotlin.io.path.readText
import kotlin.io.path.useLines
import kotlin.io.path.writeText
import kotlin.jvm.optionals.getOrNull
import org.gradle.api.DefaultTask
import org.gradle.api.Project
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.internal.plugins.PluginRegistry
import org.gradle.api.provider.MapProperty
Expand All @@ -53,17 +35,10 @@ import org.gradle.api.provider.Provider
import org.gradle.api.provider.SetProperty
import org.gradle.api.tasks.CacheableTask
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.Optional
import org.gradle.api.tasks.OutputFile
import org.gradle.api.tasks.PathSensitive
import org.gradle.api.tasks.PathSensitivity
import org.gradle.api.tasks.TaskAction
import org.gradle.api.tasks.TaskProvider
import org.gradle.api.tasks.options.Option
import org.gradle.language.base.plugins.LifecycleBasePlugin
import org.gradle.work.DisableCachingByDefault

private fun MapProperty<String, Boolean>.put(feature: ModuleFeature, provider: Provider<Boolean>) {
put(feature.name, provider.orElse(false))
Expand Down Expand Up @@ -182,232 +157,3 @@ public abstract class ModuleTopographyTask : DefaultTask() {
topography.writeJsonTo(topographyOutputFile, prettyPrint = true)
}
}

@DisableCachingByDefault
public abstract class ValidateModuleTopographyTask : DefaultTask() {
@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
@get:Optional
public abstract val featuresConfigFile: RegularFileProperty

@get:InputFile
@get:PathSensitive(PathSensitivity.NONE)
public abstract val topographyJson: RegularFileProperty

@get:Optional
@get:Option(option = "auto-fix", description = "Enables auto-fixing build files")
@get:Input
public abstract val autoFix: Property<Boolean>

@get:Internal public abstract val projectDirProperty: DirectoryProperty

@get:OutputFile public abstract val modifiedBuildFile: RegularFileProperty
@get:OutputFile public abstract val featuresToRemoveOutputFile: RegularFileProperty

init {
group = "foundry"
@Suppress("LeakingThis")
notCompatibleWithConfigurationCache("This task modified build files in place")
@Suppress("LeakingThis") doNotTrackState("This task modified build files in place")
}

@OptIn(ExperimentalPathApi::class)
@TaskAction
public fun validate() {
val topography = ModuleTopography.from(topographyJson)
val loadedFeatures =
featuresConfigFile.asFile
.map { ModuleFeaturesConfig.load(it.toPath()) }
.getOrElse(ModuleFeaturesConfig.DEFAULT)
.loadFeatures()
val features = buildSet {
addAll(topography.features.map { featureKey -> loadedFeatures.getValue(featureKey) })
// Include plugin-specific features to the check here
addAll(loadedFeatures.filterValues { it.matchingPlugin in topography.plugins }.values)
}
val featuresToRemove = mutableSetOf<ModuleFeature>()

val projectDir = projectDirProperty.asFile.get().toPath()
val srcsDir = projectDir.resolve("src")

val buildFile = projectDir.resolve("build.gradle.kts")
var buildFileText = buildFile.readText()
val initialBuildFileHash = buildFileText.hashCode()

for (feature in features) {
val initialRemoveSize = featuresToRemove.size
feature.matchingSourcesDir?.let { matchingSrcsDir ->
if (projectDir.resolve(matchingSrcsDir).walkEachFile().none()) {
featuresToRemove += feature
}
}

feature.generatedSourcesDir?.let { generatedSrcsDir ->
if (projectDir.resolve(generatedSrcsDir).walkEachFile().none()) {
featuresToRemove += feature
}
}

if (feature.matchingText.isNotEmpty()) {
if (!feature.hasMatchingTextIn(srcsDir)) {
featuresToRemove += feature
}
}

val isRemoving = featuresToRemove.size != initialRemoveSize
if (isRemoving) {
feature.removalPatterns?.let { removalPatterns ->
for (removalRegex in removalPatterns) {
buildFileText = buildFileText.replace(removalRegex, "").removeEmptyBraces()
}
}
}
}

JsonTools.toJson<Set<ModuleFeature>>(
featuresToRemoveOutputFile,
featuresToRemove.toSortedSet(compareBy { it.name }),
)

val hasBuildFileChanges = initialBuildFileHash != buildFileText.hashCode()
val shouldAutoFix = autoFix.getOrElse(false)
if (hasBuildFileChanges) {
if (shouldAutoFix) {
buildFile.writeText(buildFileText)
} else {
modifiedBuildFile.asFile.get().writeText(buildFileText)
}
}

val allAutoFixed = featuresToRemove.all { !it.removalPatterns.isNullOrEmpty() }
if (featuresToRemove.isNotEmpty()) {
val message = buildString {
appendLine(
"**Validation failed! The following features appear to be unused and can be removed.**"
)
appendLine()
var first = true
featuresToRemove.forEach {
if (first) {
first = false
} else {
appendLine()
appendLine()
}
appendLine("- **${it.name}:** ${it.explanation}")
appendLine()
appendLine(" - **Advice:** ${it.advice}")
}
appendLine()
appendLine("Full list written to ${featuresToRemoveOutputFile.asFile.get().absolutePath}")
}
val t = Terminal(AnsiLevel.TRUECOLOR, interactive = true)
val md = Markdown(message)
t.println(md, stderr = true)
if (shouldAutoFix) {
if (allAutoFixed) {
logger.lifecycle("All issues auto-fixed")
} else {
throw AssertionError("Not all issues could be fixed automatically")
}
} else {
throw AssertionError()
}
}
}

@OptIn(ExperimentalPathApi::class)
private fun ModuleFeature.hasMatchingTextIn(srcsDir: Path): Boolean {
logger.debug("Checking for $name annotation usages in sources")
return srcsDir
.walkEachFile()
.run {
if (matchingTextFileExtensions.isNotEmpty()) {
filter { it.extension in matchingTextFileExtensions }
} else {
this
}
}
.any { file ->
file.useLines { lines ->
for (line in lines) {
if (matchingText.any { it in line }) {
return@any true
}
}
}
false
}
}

internal companion object {
private const val LOG = "[ValidateModuleTopography]"
private const val NAME = "validateModuleTopography"
private val CI_NAME = "ci${NAME.capitalizeUS()}"
internal val GLOBAL_CI_NAME = "global${CI_NAME.capitalizeUS()}"

fun register(
project: Project,
topographyTask: TaskProvider<ModuleTopographyTask>,
foundryProperties: FoundryProperties,
affectedProjects: Set<String>?,
) {
val publisher =
if (affectedProjects == null || project.path in affectedProjects) {
Publisher.interProjectPublisher(project, FoundryArtifact.SKIPPY_VALIDATE_TOPOGRAPHY)
} else {
val log = "$LOG Skipping ${project.path}:$CI_NAME because it is not affected."
if (foundryProperties.debug) {
project.logger.lifecycle(log)
} else {
project.logger.debug(log)
}
SkippyArtifacts.publishSkippedTask(project, NAME)
null
}

val validateModuleTopographyTask =
project.tasks.register<ValidateModuleTopographyTask>(NAME) {
topographyJson.set(topographyTask.flatMap { it.topographyOutputFile })
featuresConfigFile.convention(foundryProperties.topographyFeaturesConfig)
projectDirProperty.set(project.layout.projectDirectory)
autoFix.convention(foundryProperties.topographyAutoFix)
featuresToRemoveOutputFile.setDisallowChanges(
project.layout.buildDirectory.file("foundry/topography/validate/featuresToRemove.json")
)
modifiedBuildFile.setDisallowChanges(
project.layout.buildDirectory.file(
"foundry/topography/validate/modified-build.gradle.kts"
)
)
}
val ciValidateModuleTopographyTask =
SimpleFileProducerTask.registerOrConfigure(
project,
CI_NAME,
description = "Lifecycle task to run $NAME for ${project.path}.",
group = LifecycleBasePlugin.VERIFICATION_GROUP,
) {
dependsOn(validateModuleTopographyTask)
}
publisher?.publish(ciValidateModuleTopographyTask)
}
}
}

//// Usage
// var code = "foundry { features { compose() } }"
// code = code.replace(Regex("\\bcompose\\(\\)"), "") // remove compose()
// code = removeEmptyBraces(code) // recursively remove empty braces
//
// println(code) // Should print "<nothing>"
// TODO write tests for this
private val EMPTY_DSL_BLOCK = "(\\w*)\\s*\\{\\s*\\}".toRegex()

internal fun String.removeEmptyBraces(): String {
var result = this
while (EMPTY_DSL_BLOCK.containsMatchIn(result)) {
result = EMPTY_DSL_BLOCK.replace(result, "")
}
return result
}
Loading

0 comments on commit a29284a

Please sign in to comment.