From cf338244793cc792cbe8e1ad2959a732f371d540 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Mon, 18 May 2026 20:54:30 +0300 Subject: [PATCH 1/2] Publish per-module Featured manifest as consumable artifact MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new consumable Gradle configuration `featuredManifest` (schema v1, Usage = "featured-manifest") that publishes a per-module `featured-manifest.json` description of the module's local and remote feature flags. Each `dev.androidbroadcast.featured` plugin application now registers a `generateFeaturedManifest` @CacheableTask that maps LocalFlagEntry rows from `flags.txt` into a self-contained FlagDescriptor list (key, propertyName, kind, valueType, defaultValue, enumTypeFqn) and serialises the result via kotlinx-serialization. The manifest is the producer side of the multi-module aggregation redesign: a follow-up PR introduces the aggregator that resolves all manifests through normal dependency resolution and generates the GeneratedFeaturedRegistry. The existing five flag-generation tasks are unchanged. A separate Maven-publish guard is intentionally omitted — custom consumable configurations are not auto-published by the Java / KMP / AGP software components, and a KMP smoke fixture gates the invariant that no `featured-manifest` variant appears in the published .module metadata. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 4 + featured-gradle-plugin/build.gradle.kts | 2 + .../featured/gradle/FeaturedPlugin.kt | 81 ++++++ .../gradle/manifest/FeaturedManifest.kt | 145 ++++++++++ .../manifest/FeaturedManifestContract.kt | 23 ++ .../manifest/GenerateFeaturedManifestTask.kt | 140 ++++++++++ .../build.gradle.kts | 7 + .../settings.gradle.kts | 9 + .../kmp-publish-project/build.gradle.kts | 1 + .../kmp-publish-project/gradle.properties | 1 + .../module/build.gradle.kts | 31 +++ .../module/src/commonMain/kotlin/.gitkeep | 0 .../kmp-publish-project/settings.gradle.kts | 17 ++ .../app/build.gradle.kts | 27 ++ .../app/src/main/AndroidManifest.xml | 1 + .../manifest-publish-project/build.gradle.kts | 1 + .../gradle.properties | 3 + .../settings.gradle.kts | 31 +++ .../manifest/FeaturedKmpPublicationTest.kt | 73 +++++ .../FeaturedManifestConfigurationTest.kt | 109 ++++++++ .../manifest/FeaturedManifestEmptyDslTest.kt | 62 +++++ .../FeaturedManifestIntegrationTest.kt | 184 +++++++++++++ .../manifest/FeaturedManifestMappingTest.kt | 250 +++++++++++++++++ .../FeaturedManifestSerializationTest.kt | 252 ++++++++++++++++++ ...ateFeaturedManifestTaskRegistrationTest.kt | 103 +++++++ .../gradle/manifest/TestFixtureSupport.kt | 50 ++++ gradle/libs.versions.toml | 3 + 27 files changed, 1610 insertions(+) create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt create mode 100644 featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt create mode 100644 featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep create mode 100644 featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties create mode 100644 featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt create mode 100644 featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index f8aea62..cfcb5e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- Featured library plugin now publishes a per-module feature-flag manifest as a consumable Gradle artifact (`featuredManifest` configuration, schema v1). Existing flag-generation pipeline is unchanged. Consumer-side aggregation arrives in a follow-up release. + ## [1.0.0-Beta1] - 2026-05-17 ### Added diff --git a/featured-gradle-plugin/build.gradle.kts b/featured-gradle-plugin/build.gradle.kts index 827e8b4..36b432e 100644 --- a/featured-gradle-plugin/build.gradle.kts +++ b/featured-gradle-plugin/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.kotlinJvm) + alias(libs.plugins.kotlinSerialization) `java-gradle-plugin` alias(libs.plugins.mavenPublish) } @@ -70,6 +71,7 @@ tasks.pluginUnderTestMetadata { dependencies { compileOnly("com.android.tools.build:gradle:9.1.0") + implementation(libs.kotlinx.serialization.json) // Inject AGP into the TestKit subprocess via pluginUnderTestMetadata so that the Featured // plugin can access AndroidComponentsExtension when wireProguardToVariants() is called. testPluginClasspath("com.android.tools.build:gradle:9.1.0") diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt index 95d9425..55bd264 100644 --- a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/FeaturedPlugin.kt @@ -1,7 +1,14 @@ package dev.androidbroadcast.featured.gradle +import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_CONFIGURATION_NAME +import dev.androidbroadcast.featured.gradle.manifest.FEATURED_MANIFEST_USAGE +import dev.androidbroadcast.featured.gradle.manifest.GENERATE_FEATURED_MANIFEST_TASK_NAME +import dev.androidbroadcast.featured.gradle.manifest.GenerateFeaturedManifestTask +import dev.androidbroadcast.featured.gradle.manifest.SCHEMA_VERSION +import dev.androidbroadcast.featured.gradle.manifest.schemaMajorAttr import org.gradle.api.Plugin import org.gradle.api.Project +import org.gradle.api.attributes.Usage import org.gradle.api.tasks.TaskProvider internal const val RESOLVE_FLAGS_TASK_NAME = "resolveFeatureFlags" @@ -54,6 +61,8 @@ public class FeaturedPlugin : Plugin { val proguardTask = registerProguardTask(target, resolveTask) registerIosConstValTask(target, resolveTask) registerXcconfigTask(target, resolveTask) + val manifestTask = registerManifestTask(target, resolveTask) + registerFeaturedManifestConfiguration(target, manifestTask) wireToRootAggregator(target, resolveTask) listOf("com.android.application", "com.android.library").forEach { pluginId -> target.plugins.withId(pluginId) { @@ -145,6 +154,78 @@ public class FeaturedPlugin : Plugin { } } + private fun registerManifestTask( + target: Project, + resolveTask: TaskProvider, + ): TaskProvider = + target.tasks.register( + GENERATE_FEATURED_MANIFEST_TASK_NAME, + GenerateFeaturedManifestTask::class.java, + ) { task -> + task.group = "featured" + task.description = "Generates featured-manifest.json for '${target.path}'." + task.flagsFile.set(resolveTask.flatMap { it.outputFile }) + // Snapshot target.path at configuration time — Project must not be captured by + // task state to remain Configuration Cache compliant. + task.modulePath.set(target.path) + task.outputFile.convention( + target.layout.buildDirectory.file("featured/featured-manifest.json"), + ) + task.dependsOn(resolveTask) + } + + private fun registerFeaturedManifestConfiguration( + target: Project, + manifestTask: TaskProvider, + ) { + // Register the schemaMajorAttr in the project's attribute schema so that Gradle's + // dependency resolution can match it precisely between producer and consumer. + target.dependencies.attributesSchema.attribute(schemaMajorAttr) + + val manifestConfiguration = + target.configurations.consumable( + FEATURED_MANIFEST_CONFIGURATION_NAME, + ) { config -> + config.attributes { + it.attribute( + Usage.USAGE_ATTRIBUTE, + target.objects.named(Usage::class.java, FEATURED_MANIFEST_USAGE), + ) + // Use SCHEMA_VERSION constant — not a hardcoded literal — so that a future bump + // automatically flows through to the attribute without a separate edit here. + it.attribute(schemaMajorAttr, SCHEMA_VERSION) + } + } + + // Wire the manifest file as an outgoing artifact. The provider chain already carries + // the task dependency; builtBy is explicit for IDE / --dry-run readability. + target.artifacts.add( + FEATURED_MANIFEST_CONFIGURATION_NAME, + manifestTask.flatMap { it.outputFile }, + ) { artifact -> + artifact.builtBy(manifestTask) + } + + // Maven-publish guard intentionally omitted (verified 2026-05-18 via KMP smoke test). + // + // The `java`, `java-library`, `kotlinMultiplatform`, and `com.android.library` software + // components do NOT auto-publish arbitrary consumable configurations. Each component + // exposes only the variants it explicitly added via `addVariantsFromConfiguration` — + // typically `apiElements` / `runtimeElements` for Java, target-specific + // `*ApiElements` / `*RuntimeElements` for KMP, build-type variants for AGP. + // + // The `featuredManifest` configuration is never registered with any of these components, + // so it does not appear in published Maven metadata. A guard via + // `withVariantsFromConfiguration(...) { skip() }` is not only unnecessary — it actively + // throws `Variant for configuration 'featuredManifest' does not exist in component` + // during publication because `withVariantsFromConfiguration` requires the variant to + // have been added first. + // + // The KMP smoke fixture (`kmp-publish-project`) and `FeaturedKmpPublicationTest` verify + // this invariant: a KMP module that applies both `dev.androidbroadcast.featured` and + // `maven-publish` produces module metadata with no `featured-manifest` Usage variant. + } + /** * Ensures the root project has a `scanAllLocalFlags` aggregation task and wires * [resolveTask] into it. `./gradlew scanAllLocalFlags` triggers flag resolution diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt new file mode 100644 index 0000000..3af02c9 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifest.kt @@ -0,0 +1,145 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** + * Root manifest published by each module that applies the Featured Gradle plugin. + * + * **Public contract is the JSON wire format documented below.** These Kotlin types are an + * internal producer-side helper. The consumer (PR B aggregator) may implement its own + * deserialization model independently — renaming internal Kotlin fields does NOT break + * the contract; changing the JSON wire format (field names, field semantics, enum variant + * names) DOES break it and requires a [SCHEMA_VERSION] bump. + * + * --- + * + * ## Example JSON + * + * ```json + * { + * "schemaVersion": 1, + * "modulePath": ":feature:checkout", + * "flags": [ + * { + * "key": "dark_mode", + * "propertyName": "darkMode", + * "kind": "LOCAL", + * "valueType": "BOOLEAN", + * "defaultValue": "false" + * }, + * { + * "key": "promo_banner", + * "propertyName": "promoBanner", + * "kind": "REMOTE", + * "valueType": "STRING", + * "defaultValue": "hello world", + * "description": "Show promo banner" + * }, + * { + * "key": "checkout_variant", + * "propertyName": "checkoutVariant", + * "kind": "LOCAL", + * "valueType": "ENUM", + * "defaultValue": "LEGACY", + * "enumTypeFqn": "com.example.CheckoutVariant" + * } + * ] + * } + * ``` + * + * --- + * + * ## Field semantics + * + * - **`modulePath`** — Gradle `Project.path` in the `:foo:bar` format. Root project is `":"`. + * - **`propertyName`** — camelCase Kotlin property name for the aggregator to generate. + * Derived from `key` via `toCamelCase()` in the producer. + * - **`kind`** — `LOCAL` for flags declared in `localFlags { }`, `REMOTE` for `remoteFlags { }`. + * - **`defaultValue`** — raw default value as a string. For `STRING` valueType, the enclosing + * quotes (added by `FlagContainer.string()`) are removed by the producer; the stored value + * is the bare string (e.g. `hello world`, not `"hello world"`). For `ENUM` valueType, only + * the constant name is stored (e.g. `LEGACY`, not `EnumClass.LEGACY`) so that the aggregator + * can pass it directly to `enumValueOf(defaultValue)`. + * - **`enumTypeFqn`** — fully-qualified name of the enum class when `valueType` is `ENUM`; + * `null` for all other types. + * - **`description`**, **`category`**, **`expiresAt`** — optional metadata passed through from + * the DSL. Absent from JSON when null (`explicitNulls = false`). + * + * --- + * + * ## Evolvability policy + * + * | Change | Action | + * |---------------------------------------------|----------------------------------------------| + * | Add optional field with a default | Additive — no schema bump | + * | Remove or rename existing field | Breaking — bump [SCHEMA_VERSION] + `schema-major` attribute | + * | Add new enum variant in [FlagKind]/[ValueType] | Breaking — bump major | + * | Change semantics of existing field | Breaking — bump major | + * + * --- + * + * ## ABI status + * + * The `featured-manifest` Usage attribute and the `schema-major` Gradle attribute are stable + * consumer-facing contracts. See [FeaturedManifestContract] for the attribute constants. + */ +@Serializable +internal data class FeaturedManifest( + val schemaVersion: Int, + val modulePath: String, + // No default value — guarantees that an empty list is serialized as "flags":[] + // rather than being omitted when encodeDefaults = false. + val flags: List, +) + +/** + * Describes a single feature flag declared via the `featured { }` DSL. + * + * Null optional fields are omitted from the JSON output (`explicitNulls = false` in + * [FeaturedManifestJson]). + */ +@Serializable +internal data class FlagDescriptor( + val key: String, + val propertyName: String, + val kind: FlagKind, + val valueType: ValueType, + val defaultValue: String, + val enumTypeFqn: String? = null, + val description: String? = null, + val category: String? = null, + val expiresAt: String? = null, +) + +/** Whether the flag is declared in `localFlags { }` or `remoteFlags { }`. */ +@Serializable +internal enum class FlagKind { LOCAL, REMOTE } + +/** The Kotlin type of the flag's value. */ +@Serializable +internal enum class ValueType { BOOLEAN, INT, LONG, FLOAT, DOUBLE, STRING, ENUM } + +/** Wire-format schema version. Bump this (and the `schema-major` Gradle attribute) on breaking changes. */ +internal const val SCHEMA_VERSION = 1 + +/** + * Pre-configured [Json] instance used for both encoding and decoding [FeaturedManifest]. + * + * - `prettyPrint = true` — human-readable output for easier debugging and diff review. + * - `explicitNulls = false` — null optional fields are omitted from the JSON, keeping + * the output compact and forward-compatible. + * - `encodeDefaults = false` — Kotlin default values are not written if they match the + * declared default. Note: [FeaturedManifest.flags] intentionally has **no** default so + * it is always serialized, even when empty. + * - `ignoreUnknownKeys = true` — forward-compatible decoding: a consumer built against + * schema v1 can safely read a manifest produced by a future schema version that added + * optional fields. + */ +internal val FeaturedManifestJson = + Json { + prettyPrint = true + explicitNulls = false + encodeDefaults = false + ignoreUnknownKeys = true + } diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt new file mode 100644 index 0000000..f4a2409 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestContract.kt @@ -0,0 +1,23 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.api.attributes.Attribute + +internal const val GENERATE_FEATURED_MANIFEST_TASK_NAME = "generateFeaturedManifest" +internal const val FEATURED_MANIFEST_CONFIGURATION_NAME = "featuredManifest" +internal const val FEATURED_MANIFEST_USAGE = "featured-manifest" +internal const val SCHEMA_MAJOR_ATTRIBUTE_NAME = "dev.androidbroadcast.featured.schema-major" + +/** + * Gradle attribute that carries the major version of the Featured manifest schema. + * + * The attribute is declared as `Attribute` for ergonomic use from Kotlin call sites + * (`attribute(schemaMajorAttr, SCHEMA_VERSION)`). Under the hood Kotlin maps `Int` in a + * generic position to `java.lang.Integer`, which is the JVM boxed type Gradle uses for + * attribute equality. [Int.javaObjectType] (`Int::class.javaObjectType`) returns exactly + * `Integer.class`, so this is wire-compatible with a Java-side `Attribute`. + * + * The consumer (PR B aggregator) must declare the same [Attribute] instance — sharing + * this constant guarantees a single Attribute object. + */ +internal val schemaMajorAttr: Attribute = + Attribute.of(SCHEMA_MAJOR_ATTRIBUTE_NAME, Int::class.javaObjectType) diff --git a/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt new file mode 100644 index 0000000..e08b3e5 --- /dev/null +++ b/featured-gradle-plugin/src/main/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTask.kt @@ -0,0 +1,140 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import dev.androidbroadcast.featured.gradle.LocalFlagEntry +import dev.androidbroadcast.featured.gradle.parseLocalFlagEntries +import kotlinx.serialization.encodeToString +import org.gradle.api.DefaultTask +import org.gradle.api.file.RegularFileProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.CacheableTask +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.OutputFile +import org.gradle.api.tasks.PathSensitive +import org.gradle.api.tasks.PathSensitivity +import org.gradle.api.tasks.TaskAction + +/** + * Generates the per-module `featured-manifest.json` artifact consumed by the PR-B aggregator. + * + * Reads the flag report from [flagsFile] (produced by `resolveFeatureFlags`), maps each + * [LocalFlagEntry] to a [FlagDescriptor], and writes the result as a JSON document to + * [outputFile]. + * + * The output file is published via the `featuredManifest` consumable Gradle configuration + * so that downstream aggregator modules can resolve all per-module manifests through + * normal dependency resolution. + */ +@CacheableTask +internal abstract class GenerateFeaturedManifestTask : DefaultTask() { + /** + * The pipe-delimited flag report produced by `resolveFeatureFlags`. + * + * [PathSensitivity.NONE] is used because this is a generated intermediate file whose + * absolute path varies across machines and build directories. Only the file content + * matters for cache key computation — the path itself is irrelevant to correctness. + * This matches the sensitivity used by all other Generate* tasks that consume the same + * flags.txt file. + */ + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + abstract val flagsFile: RegularFileProperty + + /** + * Gradle `Project.path` for this module (e.g. `":feature:checkout"`, `":"`). + * + * Set as a snapshot string at configuration time (not as a lazy provider) to ensure + * Configuration Cache compliance — `Project` instances must not be captured by task + * state at execution time. + */ + @get:Input + abstract val modulePath: Property + + /** + * Output path for the generated `featured-manifest.json`. + * + * The convention `build/featured/featured-manifest.json` is wired by [FeaturedPlugin]; + * it keeps all Featured build outputs under a single directory alongside `flags.txt` + * and `proguard-featured.pro`. + */ + @get:OutputFile + abstract val outputFile: RegularFileProperty + + @TaskAction + fun generate() { + val path = modulePath.get() + require(path.startsWith(":")) { + "modulePath must be a Gradle path starting with ':', was '$path'" + } + + val entries = flagsFile.parseLocalFlagEntries() + val descriptors = entries.map { entry -> entry.toFlagDescriptor() } + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = path, + flags = descriptors, + ) + + val outFile = outputFile.get().asFile + outFile.parentFile?.mkdirs() + outFile.writeText(FeaturedManifestJson.encodeToString(manifest)) + + logger.lifecycle( + "[featured] Generated manifest with ${descriptors.size} flag(s) → ${outFile.path}", + ) + } +} + +internal fun LocalFlagEntry.toFlagDescriptor(): FlagDescriptor { + val kind = if (isLocal) FlagKind.LOCAL else FlagKind.REMOTE + + val valueType = + if (isEnum) { + ValueType.ENUM + } else { + when (type) { + "Boolean" -> ValueType.BOOLEAN + + "Int" -> ValueType.INT + + "Long" -> ValueType.LONG + + "Float" -> ValueType.FLOAT + + "Double" -> ValueType.DOUBLE + + "String" -> ValueType.STRING + + // Explicit error with key name — ValueType.valueOf(type.uppercase()) would produce + // a cryptic "No enum constant" message with no context about which flag failed. + else -> error("Unsupported flag value type '$type' for key '$key'") + } + } + + val resolvedDefault = + when (valueType) { + // FlagContainer.string() wraps the default in escaped quotes: defaultValue = "\"hello\"". + // ScanResultParser stores it verbatim. Strip the surrounding quotes here so the aggregator + // can use the bare value without further processing. + ValueType.STRING -> defaultValue.removeSurrounding("\"") + + // ConfigParamGenerator writes qualified form "EnumType.VARIANT"; only the constant + // name is useful for the aggregator (it calls enumValueOf(defaultValue)). + ValueType.ENUM -> defaultValue.substringAfterLast('.') + + else -> defaultValue + } + + return FlagDescriptor( + key = key, + propertyName = propertyName, + kind = kind, + valueType = valueType, + defaultValue = resolvedDefault, + enumTypeFqn = type.takeIf { isEnum }, + description = description, + category = category, + expiresAt = expiresAt, + ) +} diff --git a/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts new file mode 100644 index 0000000..6147eee --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/build.gradle.kts @@ -0,0 +1,7 @@ +plugins { + id("java-library") + id("dev.androidbroadcast.featured") +} + +// No featured { } block — the plugin is applied with zero flag declarations. +// Expected: generateFeaturedManifest produces a manifest with an empty flags array. diff --git a/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts new file mode 100644 index 0000000..d0f39ff --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/jvm-empty-featured-project/settings.gradle.kts @@ -0,0 +1,9 @@ +// The Featured plugin is injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +rootProject.name = "jvm-empty-featured-project" diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts new file mode 100644 index 0000000..b1af0dc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/build.gradle.kts @@ -0,0 +1 @@ +// Root build file — no plugins applied at root level. diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties new file mode 100644 index 0000000..5ad6974 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/gradle.properties @@ -0,0 +1 @@ +org.gradle.configuration-cache=true diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts new file mode 100644 index 0000000..ccf89fb --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + id("org.jetbrains.kotlin.multiplatform") version "2.3.10" + id("dev.androidbroadcast.featured") + id("maven-publish") +} + +kotlin { + jvm() + + sourceSets { + commonMain {} + } +} + +group = "com.example.test" +version = "0.1.0" + +featured { + localFlags { + boolean("debug_overlay", default = false) + } +} + +publishing { + repositories { + maven { + name = "TestLocal" + url = uri(layout.buildDirectory.dir("test-repo")) + } + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/module/src/commonMain/kotlin/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts new file mode 100644 index 0000000..ac37701 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/kmp-publish-project/settings.gradle.kts @@ -0,0 +1,17 @@ +// The Featured plugin and Kotlin Multiplatform plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + gradlePluginPortal() + mavenCentral() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + mavenCentral() + } +} + +rootProject.name = "kmp-publish-project" +include(":module") diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts new file mode 100644 index 0000000..1b46cb2 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + id("com.android.library") version "9.1.0" + id("dev.androidbroadcast.featured") +} + +android { + namespace = "com.example.testapp" + compileSdk = 36 + + defaultConfig { + minSdk = 24 + } +} + +featured { + localFlags { + boolean("dark_mode", default = false) { + category = "UI" + } + enum("checkout_variant", typeFqn = "com.example.CheckoutVariant", default = "LEGACY") + } + remoteFlags { + boolean("promo_banner", default = false) { + description = "Show promo banner" + } + } +} diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..94cbbcf --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/app/src/main/AndroidManifest.xml @@ -0,0 +1 @@ + diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts new file mode 100644 index 0000000..b1af0dc --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/build.gradle.kts @@ -0,0 +1 @@ +// Root build file — no plugins applied at root level. diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties new file mode 100644 index 0000000..d621155 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/gradle.properties @@ -0,0 +1,3 @@ +android.useAndroidX=true +org.gradle.configuration-cache=true +org.gradle.unsafe.configuration-cache-problems=warn diff --git a/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts new file mode 100644 index 0000000..ae64af0 --- /dev/null +++ b/featured-gradle-plugin/src/test/fixtures/manifest-publish-project/settings.gradle.kts @@ -0,0 +1,31 @@ +// AGP and the Featured plugin are injected via GradleRunner.withPluginClasspath(). +pluginManagement { + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + @Suppress("UnstableApiUsage") + repositories { + google { + mavenContent { + includeGroupAndSubgroups("androidx") + includeGroupAndSubgroups("com.android") + includeGroupAndSubgroups("com.google") + } + } + mavenCentral() + } +} + +rootProject.name = "manifest-publish-project" +include(":app") diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt new file mode 100644 index 0000000..a8a0686 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedKmpPublicationTest.kt @@ -0,0 +1,73 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * Smoke test that verifies the `featuredManifest` consumable configuration does NOT leak + * into the published Gradle Module Metadata (`.module` JSON) for a KMP module. + * + * Custom consumable configurations with arbitrary `Usage` attributes are not auto-published + * by the `kotlinMultiplatform`, `java`, `java-library`, or AGP software components — each + * component exposes only the variants it explicitly added via `addVariantsFromConfiguration`. + * This test is the mandatory gate that confirms that invariant in practice for KMP. + * + * Uses the `kmp-publish-project` fixture (JVM-only KMP module) to avoid requiring the + * Kotlin/Native toolchain download that `iosX64()` would trigger on CI. + */ +class FeaturedKmpPublicationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `publishing KMP module does not expose featuredManifest variant in module metadata`() { + val projectDir = tempFolder.newFolder("kmp-publish-project") + copyManifestFixture("kmp-publish-project", projectDir) + + val result = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(":module:publishAllPublicationsToTestLocalRepository", "--stacktrace") + .forwardOutput() + .build() + + val outcome = result.task(":module:publishAllPublicationsToTestLocalRepository")?.outcome + assertTrue( + outcome == TaskOutcome.SUCCESS || outcome == TaskOutcome.UP_TO_DATE, + "Expected publish task to succeed, got $outcome\n${result.output}", + ) + + // Locate the generated .module file in the test-local repo. + val repoDir = projectDir.resolve("module/build/test-repo") + val moduleFiles = repoDir.walkTopDown().filter { it.extension == "module" }.toList() + assertTrue( + moduleFiles.isNotEmpty(), + "Expected at least one .module file in ${repoDir.path}; found none.\n${result.output}", + ) + + moduleFiles.forEach { moduleFile -> + val moduleJson = moduleFile.readText() + + // The featuredManifest Usage must not appear in any published variant. + assertFalse( + moduleJson.contains(FEATURED_MANIFEST_USAGE), + "Found '$FEATURED_MANIFEST_USAGE' in published .module metadata at ${moduleFile.path}.\n" + + "The featuredManifest configuration must be excluded from Maven publication.\n" + + "Content:\n$moduleJson", + ) + + // Sanity check: the .module file is valid and has variants. + assertTrue( + moduleJson.contains("\"variants\""), + "Expected 'variants' key in .module metadata at ${moduleFile.path}", + ) + } + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt new file mode 100644 index 0000000..5a0a5c1 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestConfigurationTest.kt @@ -0,0 +1,109 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.api.attributes.Usage +import org.gradle.testfixtures.ProjectBuilder +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +@Suppress("UnstableApiUsage") +class FeaturedManifestConfigurationTest { + @Test + fun `featuredManifest configuration is registered`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg, "Expected '$FEATURED_MANIFEST_CONFIGURATION_NAME' configuration to be registered") + } + + @Test + fun `featuredManifest configuration has correct consumable flags`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue(cfg.isCanBeConsumed, "Expected isCanBeConsumed = true") + assertTrue(!cfg.isCanBeResolved, "Expected isCanBeResolved = false") + assertTrue(!cfg.isCanBeDeclared, "Expected isCanBeDeclared = false") + } + + @Test + fun `featuredManifest configuration has usage attribute set to featured-manifest`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + val usageAttr = cfg.attributes.getAttribute(Usage.USAGE_ATTRIBUTE) + assertNotNull(usageAttr, "Expected Usage attribute to be set") + assertEquals( + FEATURED_MANIFEST_USAGE, + usageAttr.name, + "Expected usage name '$FEATURED_MANIFEST_USAGE' but was '${usageAttr.name}'", + ) + } + + @Test + fun `featuredManifest configuration has schema-major attribute set to SCHEMA_VERSION`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + val schemaAttr = cfg.attributes.getAttribute(schemaMajorAttr) + assertNotNull(schemaAttr, "Expected schema-major attribute to be set") + assertEquals( + SCHEMA_VERSION, + schemaAttr, + "Expected schema-major attribute = $SCHEMA_VERSION but was $schemaAttr", + ) + } + + @Test + fun `featuredManifest configuration has outgoing artifacts`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + assertTrue( + cfg.outgoing.artifacts.isNotEmpty(), + "Expected at least one outgoing artifact on '$FEATURED_MANIFEST_CONFIGURATION_NAME'", + ) + } + + @Test + fun `featuredManifest artifact is built by generateFeaturedManifest task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val cfg = project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + assertNotNull(cfg) + val deps = + cfg.outgoing.artifacts.buildDependencies + .getDependencies(null) + val taskNames = deps.map { it.name } + assertTrue( + taskNames.contains(GENERATE_FEATURED_MANIFEST_TASK_NAME), + "Expected artifact built by '$GENERATE_FEATURED_MANIFEST_TASK_NAME', got: $taskNames", + ) + } + + @Test + fun `accessing featuredManifest configuration does not eagerly realize generateFeaturedManifest task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + // Accessing the configuration by name must not trigger task realization. + project.configurations.findByName(FEATURED_MANIFEST_CONFIGURATION_NAME) + + // The task must still be present in the task graph (registered lazily). + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_MANIFEST_TASK_NAME), + "Expected '$GENERATE_FEATURED_MANIFEST_TASK_NAME' to be in task names (lazy)", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt new file mode 100644 index 0000000..500abbc --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestEmptyDslTest.kt @@ -0,0 +1,62 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Verifies that applying the Featured plugin without any `featured { }` DSL block generates + * a manifest with an empty `flags` array (not omitted) and the correct `schemaVersion`. + * + * Uses Gradle TestKit because `afterEvaluate` (which wires the DSL into the resolve task) + * is not triggered by ProjectBuilder — only a real Gradle execution resolves the full lifecycle. + */ +class FeaturedManifestEmptyDslTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @Test + fun `generateFeaturedManifest with no DSL block produces manifest with empty flags array`() { + val projectDir = tempFolder.newFolder("jvm-empty-featured-project") + copyManifestFixture("jvm-empty-featured-project", projectDir) + + val result = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .withArguments(GENERATE_FEATURED_MANIFEST_TASK_NAME, "--stacktrace") + .forwardOutput() + .build() + + val outcome = result.task(":$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :$GENERATE_FEATURED_MANIFEST_TASK_NAME to succeed, got $outcome\n${result.output}", + ) + + val manifestFile = projectDir.resolve("build/featured/featured-manifest.json") + assertTrue(manifestFile.exists(), "Expected featured-manifest.json to be generated at ${manifestFile.path}") + + val rawJson = manifestFile.readText() + + // Parse and verify schema. + val manifest = FeaturedManifestJson.decodeFromString(rawJson) + assertEquals(SCHEMA_VERSION, manifest.schemaVersion) + // Plugin is applied to the rootProject in this single-module fixture, so the + // captured Project.path is ":". This verifies the contract for root-project apply. + assertEquals(":", manifest.modulePath, "Expected modulePath ':' for root project apply") + assertTrue(manifest.flags.isEmpty(), "Expected empty flags list, got: ${manifest.flags}") + + // Verify the raw JSON contains "flags": [] explicitly — not omitted. + assertTrue( + rawJson.contains("\"flags\": []"), + "Expected 'flags': [] in raw JSON — empty list must not be omitted, got:\n$rawJson", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt new file mode 100644 index 0000000..6a9c18b --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt @@ -0,0 +1,184 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import org.gradle.testkit.runner.GradleRunner +import org.gradle.testkit.runner.TaskOutcome +import org.junit.Assume.assumeTrue +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Integration tests for the per-module Featured manifest generation using the + * `manifest-publish-project` fixture (Android library with local and remote flags). + * + * Skipped when `ANDROID_HOME` / `ANDROID_SDK_ROOT` is not set. + */ +class FeaturedManifestIntegrationTest { + @get:Rule + val tempFolder = TemporaryFolder() + + private lateinit var projectDir: File + + @Before + fun setUp() { + val sdkDir = androidSdkDirOrNull() + assumeTrue( + "ANDROID_HOME or ANDROID_SDK_ROOT must be set to run integration tests", + sdkDir != null, + ) + + projectDir = tempFolder.newFolder("manifest-publish-project") + copyManifestFixture("manifest-publish-project", projectDir) + projectDir.resolve("local.properties").writeText("sdk.dir=${sdkDir!!.absolutePath}\n") + } + + @Test + fun `generateFeaturedManifest produces manifest with correct content`() { + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", "--stacktrace") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :app:$GENERATE_FEATURED_MANIFEST_TASK_NAME to succeed, got $outcome\n${result.output}", + ) + + val manifest = readManifest() + assertEquals(SCHEMA_VERSION, manifest.schemaVersion) + assertEquals(":app", manifest.modulePath) + assertEquals(3, manifest.flags.size, "Expected 3 flags (dark_mode, checkout_variant, promo_banner)") + + val darkMode = manifest.flags.first { it.key == "dark_mode" } + assertEquals(FlagKind.LOCAL, darkMode.kind) + assertEquals(ValueType.BOOLEAN, darkMode.valueType) + + val promoBanner = manifest.flags.first { it.key == "promo_banner" } + assertEquals(FlagKind.REMOTE, promoBanner.kind) + assertEquals(ValueType.BOOLEAN, promoBanner.valueType) + + val checkoutVariant = manifest.flags.first { it.key == "checkout_variant" } + assertEquals(FlagKind.LOCAL, checkoutVariant.kind) + assertEquals(ValueType.ENUM, checkoutVariant.valueType) + assertEquals("com.example.CheckoutVariant", checkoutVariant.enumTypeFqn) + } + + @Test + fun `second run without changes reports UP_TO_DATE`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertTrue( + outcome == TaskOutcome.UP_TO_DATE || outcome == TaskOutcome.FROM_CACHE, + "Expected :app:$GENERATE_FEATURED_MANIFEST_TASK_NAME to be UP_TO_DATE or FROM_CACHE on second run, got $outcome", + ) + } + + @Test + fun `adding a new flag invalidates the task`() { + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + // Append a new local flag to the app build script to invalidate inputs. + val buildFile = projectDir.resolve("app/build.gradle.kts") + buildFile.writeText( + buildFile.readText().replace( + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")", + "enum(\"checkout_variant\", typeFqn = \"com.example.CheckoutVariant\", default = \"LEGACY\")\n" + + " int(\"max_retries\", default = 3)", + ), + ) + + val result = + gradleRunner() + .withArguments(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME") + .build() + + val outcome = result.task(":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME")?.outcome + assertEquals( + TaskOutcome.SUCCESS, + outcome, + "Expected :app:$GENERATE_FEATURED_MANIFEST_TASK_NAME to re-run after input change, got $outcome", + ) + + val manifest = readManifest() + assertEquals(4, manifest.flags.size, "Expected 4 flags after adding max_retries") + } + + @Test + fun `configuration cache stores on first run`() { + val result = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + // Gradle does not create build/reports/configuration-cache/ unless there are CC problems + // to report. The canonical signal that the cache was stored is the output line. + assertTrue( + result.output.contains("Configuration cache entry stored"), + "Expected 'Configuration cache entry stored' in output, got:\n${result.output}", + ) + } + + @Test + fun `configuration cache is reused on second run`() { + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + val secondRun = + gradleRunner() + .withArguments( + ":app:$GENERATE_FEATURED_MANIFEST_TASK_NAME", + "--configuration-cache", + "--configuration-cache-problems=warn", + ).build() + + assertTrue( + secondRun.output.contains("Configuration cache entry reused") || + secondRun.output.contains("Reusing configuration cache"), + "Expected CC reuse marker in second-run output, got:\n${secondRun.output}", + ) + } + + // Configuration exposure (consumable flags, Usage / schema-major attributes, outgoing + // artifact and task dependency) is covered by FeaturedManifestConfigurationTest via + // ProjectBuilder — verifying that here through `:outgoingVariants` triggers a known + // ConcurrentModificationException in AGP 9.1.0 when Android's per-variant configurations + // are iterated alongside our consumable one. + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun readManifest(): FeaturedManifest { + val file = projectDir.resolve("app/build/featured/featured-manifest.json") + assertTrue(file.exists(), "Expected featured-manifest.json at ${file.path}") + return FeaturedManifestJson.decodeFromString(file.readText()) + } + + private fun gradleRunner(): GradleRunner = + GradleRunner + .create() + .withProjectDir(projectDir) + .withPluginClasspath() + .forwardOutput() +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt new file mode 100644 index 0000000..200fd84 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestMappingTest.kt @@ -0,0 +1,250 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import dev.androidbroadcast.featured.gradle.LocalFlagEntry +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FeaturedManifestMappingTest { + // ── ValueType mapping ────────────────────────────────────────────────────── + + @Test + fun `Boolean type maps to BOOLEAN ValueType`() { + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.BOOLEAN, descriptor.valueType) + } + + @Test + fun `Int type maps to INT ValueType`() { + val entry = localEntry(key = "flag", type = "Int", defaultValue = "0") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.INT, descriptor.valueType) + } + + @Test + fun `Long type maps to LONG ValueType`() { + val entry = localEntry(key = "flag", type = "Long", defaultValue = "0") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.LONG, descriptor.valueType) + } + + @Test + fun `Float type maps to FLOAT ValueType`() { + val entry = localEntry(key = "flag", type = "Float", defaultValue = "1.5") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.FLOAT, descriptor.valueType) + } + + @Test + fun `Double type maps to DOUBLE ValueType`() { + val entry = localEntry(key = "flag", type = "Double", defaultValue = "3.14") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.DOUBLE, descriptor.valueType) + } + + @Test + fun `String type maps to STRING ValueType`() { + val entry = localEntry(key = "flag", type = "String", defaultValue = "\"hello\"") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.STRING, descriptor.valueType) + } + + // ── FlagKind mapping ─────────────────────────────────────────────────────── + + @Test + fun `local flagType maps to LOCAL FlagKind`() { + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_LOCAL) + assertEquals(FlagKind.LOCAL, entry.toFlagDescriptor().kind) + } + + @Test + fun `remote flagType maps to REMOTE FlagKind`() { + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_REMOTE) + assertEquals(FlagKind.REMOTE, entry.toFlagDescriptor().kind) + } + + // ── String default value unwrapping ──────────────────────────────────────── + + @Test + fun `String defaultValue with surrounding quotes is unwrapped`() { + val entry = localEntry(key = "greeting", type = "String", defaultValue = "\"hello\"") + val descriptor = entry.toFlagDescriptor() + assertEquals("hello", descriptor.defaultValue) + } + + @Test + fun `String defaultValue without surrounding quotes is kept as-is`() { + // ScanResultParser stores the raw value; this tests what happens with bare strings. + val entry = localEntry(key = "greeting", type = "String", defaultValue = "hello") + val descriptor = entry.toFlagDescriptor() + // removeSurrounding("\"") does nothing when the value does not start and end with " + assertEquals("hello", descriptor.defaultValue) + } + + // ── Enum mapping ────────────────────────────────────────────────────────── + + @Test + fun `enum entry maps to ENUM ValueType with enumTypeFqn and stripped constant name`() { + val entry = + LocalFlagEntry( + key = "checkout_variant", + defaultValue = "com.example.CheckoutVariant.FAST", + type = "com.example.CheckoutVariant", + moduleName = ":app", + propertyName = "checkoutVariant", + flagType = LocalFlagEntry.FLAG_TYPE_LOCAL, + ) + assertTrue(entry.isEnum, "Expected isEnum = true for FQN type") + val descriptor = entry.toFlagDescriptor() + assertEquals(ValueType.ENUM, descriptor.valueType) + assertEquals("com.example.CheckoutVariant", descriptor.enumTypeFqn) + // Only the constant name — not the FQN — is stored in defaultValue. + assertEquals("FAST", descriptor.defaultValue) + } + + @Test + fun `enum entry does not strip enumTypeFqn when isEnum is false`() { + // isEnum is computed as '.' in type — a type without dots is not an enum. + val entry = localEntry(key = "flag", type = "Boolean", defaultValue = "false") + assertNull(entry.toFlagDescriptor().enumTypeFqn) + } + + // ── Unknown type error ───────────────────────────────────────────────────── + + @Test + fun `unknown type throws IllegalStateException containing type and key`() { + val entry = localEntry(key = "my_date_flag", type = "Date", defaultValue = "2026-01-01") + val ex = assertFailsWith { entry.toFlagDescriptor() } + assertTrue(ex.message?.contains("Date") == true, "Error message must contain the type 'Date', got: ${ex.message}") + assertTrue(ex.message?.contains("my_date_flag") == true, "Error message must contain the key 'my_date_flag', got: ${ex.message}") + } + + // ── Optional metadata fields ─────────────────────────────────────────────── + + @Test + fun `null optional fields are passed through as null`() { + val entry = + LocalFlagEntry( + key = "flag", + defaultValue = "false", + type = "Boolean", + moduleName = ":app", + propertyName = "flag", + description = null, + category = null, + expiresAt = null, + ) + val descriptor = entry.toFlagDescriptor() + assertNull(descriptor.description) + assertNull(descriptor.category) + assertNull(descriptor.expiresAt) + } + + @Test + fun `non-null optional fields are preserved in FlagDescriptor`() { + val entry = + LocalFlagEntry( + key = "flag", + defaultValue = "false", + type = "Boolean", + moduleName = ":app", + propertyName = "flag", + description = "A useful flag", + category = "UI", + expiresAt = "2027-01-01", + ) + val descriptor = entry.toFlagDescriptor() + assertEquals("A useful flag", descriptor.description) + assertEquals("UI", descriptor.category) + assertEquals("2027-01-01", descriptor.expiresAt) + } + + // ── Non-ASCII key ────────────────────────────────────────────────────────── + + @Test + fun `non-ASCII key is passed through to FlagDescriptor unchanged`() { + // toCamelCase() splits on '_' and uppercases each word's first char. + // For "тёмная_тема": ["тёмная", "тема"] → "тёмная" + "Тема" = "тёмнаяТема" + val entry = + LocalFlagEntry( + key = "тёмная_тема", + defaultValue = "false", + type = "Boolean", + moduleName = ":app", + propertyName = + "тёмная_тема" + .split("_") + .mapIndexed { i, w -> + if (i == 0) w.lowercase() else w.replaceFirstChar { it.uppercase() } + }.joinToString(""), + ) + val descriptor = entry.toFlagDescriptor() + assertEquals("тёмная_тема", descriptor.key) + // propertyName is passed through as-is from the entry. + assertEquals("тёмнаяТема", descriptor.propertyName) + } + + // ── Pipe separator in String default value ───────────────────────────────── + + @Test + fun `pipe character in String default value is a known parser limitation`() { + // NOTE: ScanResultParser splits lines by '|' — strings whose value contains '|' break + // the pipe-delimited format and inflate the field count past the supported sizes + // (4 / 6 / 7 / 9). Lines that do not match a known field count are silently dropped + // (parseLine returns null). + // + // FlagContainer.string() wraps the default in escaped quotes when serialising, so the + // raw line for `string("my_flag", default = "a|b")` looks like: + // my_flag|"a|b"|String|:app|myFlag|local||| + // which splits into 10 parts instead of the expected 9 — the parser silently drops it. + // + // This test documents the limitation; a future minor PR may add `require('|' !in default)` + // to FlagContainer.string() to fail fast at configuration time instead of silently. + val rawLine = "my_flag|\"a|b\"|String|:app|myFlag|local|||" + val parts = rawLine.split("|") + assertEquals( + 10, + parts.size, + "A '|' inside defaultValue inflates the field count past 9; parser will return null and silently drop the entry", + ) + } + + // ── Same key in local and remote ─────────────────────────────────────────── + + @Test + fun `same key for local and remote entries produces distinct FlagDescriptors with different kinds`() { + // Conflict detection (which entry wins, deduplication) is handled in PR B. + // The mapper itself produces two FlagDescriptors and does not deduplicate. + val local = localEntry(key = "promo", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_LOCAL) + val remote = localEntry(key = "promo", type = "Boolean", defaultValue = "false", flagType = LocalFlagEntry.FLAG_TYPE_REMOTE) + + val localDescriptor = local.toFlagDescriptor() + val remoteDescriptor = remote.toFlagDescriptor() + + assertEquals("promo", localDescriptor.key) + assertEquals("promo", remoteDescriptor.key) + assertEquals(FlagKind.LOCAL, localDescriptor.kind) + assertEquals(FlagKind.REMOTE, remoteDescriptor.kind) + } + + // ── Helpers ─────────────────────────────────────────────────────────────── + + private fun localEntry( + key: String, + type: String, + defaultValue: String, + flagType: String = LocalFlagEntry.FLAG_TYPE_LOCAL, + propertyName: String = key, + ): LocalFlagEntry = + LocalFlagEntry( + key = key, + defaultValue = defaultValue, + type = type, + moduleName = ":app", + propertyName = propertyName, + flagType = flagType, + ) +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt new file mode 100644 index 0000000..3aef5b4 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestSerializationTest.kt @@ -0,0 +1,252 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class FeaturedManifestSerializationTest { + @Test + fun `round-trip produces identical object`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature:checkout", + flags = + listOf( + FlagDescriptor( + key = "dark_mode", + propertyName = "darkMode", + kind = FlagKind.LOCAL, + valueType = ValueType.BOOLEAN, + defaultValue = "false", + ), + ), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + val decoded = FeaturedManifestJson.decodeFromString(json) + + assertEquals(manifest, decoded) + } + + @Test + fun `schemaVersion is present explicitly in JSON output`() { + val manifest = + FeaturedManifest( + schemaVersion = 1, + modulePath = ":", + flags = emptyList(), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + + assertTrue(json.contains("\"schemaVersion\""), "Expected 'schemaVersion' field in JSON") + assertTrue(json.contains("\"schemaVersion\": 1"), "Expected schemaVersion value 1 in JSON") + } + + @Test + fun `empty flags list serializes as empty array not omitted`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":", + flags = emptyList(), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + + // Must appear as "flags": [] not be absent + assertTrue(json.contains("\"flags\": []"), "Expected 'flags': [] in JSON, got:\n$json") + } + + @Test + fun `null optional fields are omitted from JSON`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":app", + flags = + listOf( + FlagDescriptor( + key = "feature", + propertyName = "feature", + kind = FlagKind.REMOTE, + valueType = ValueType.BOOLEAN, + defaultValue = "true", + enumTypeFqn = null, + description = null, + category = null, + expiresAt = null, + ), + ), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + + assertFalse(json.contains("enumTypeFqn"), "Null enumTypeFqn must be omitted from JSON") + assertFalse(json.contains("description"), "Null description must be omitted from JSON") + assertFalse(json.contains("category"), "Null category must be omitted from JSON") + assertFalse(json.contains("expiresAt"), "Null expiresAt must be omitted from JSON") + } + + @Test + fun `enum flag round-trip preserves enumTypeFqn`() { + val manifest = + FeaturedManifest( + schemaVersion = SCHEMA_VERSION, + modulePath = ":feature:checkout", + flags = + listOf( + FlagDescriptor( + key = "checkout_variant", + propertyName = "checkoutVariant", + kind = FlagKind.LOCAL, + valueType = ValueType.ENUM, + defaultValue = "LEGACY", + enumTypeFqn = "com.example.CheckoutVariant", + ), + ), + ) + + val json = FeaturedManifestJson.encodeToString(manifest) + val decoded = FeaturedManifestJson.decodeFromString(json) + + assertEquals("com.example.CheckoutVariant", decoded.flags.first().enumTypeFqn) + assertEquals("LEGACY", decoded.flags.first().defaultValue) + } + + @Test + fun `Float and Double valueTypes round-trip correctly`() { + val flags = + listOf( + FlagDescriptor( + key = "float_flag", + propertyName = "floatFlag", + kind = FlagKind.LOCAL, + valueType = ValueType.FLOAT, + defaultValue = "1.5", + ), + FlagDescriptor( + key = "double_flag", + propertyName = "doubleFlag", + kind = FlagKind.REMOTE, + valueType = ValueType.DOUBLE, + defaultValue = "3.14", + ), + ) + val manifest = FeaturedManifest(schemaVersion = SCHEMA_VERSION, modulePath = ":", flags = flags) + + val json = FeaturedManifestJson.encodeToString(manifest) + val decoded = FeaturedManifestJson.decodeFromString(json) + + assertEquals(ValueType.FLOAT, decoded.flags[0].valueType) + assertEquals(ValueType.DOUBLE, decoded.flags[1].valueType) + } + + @Test + fun `unknown JSON field during decode does not throw (forward-compatible)`() { + val json = + """ + { + "schemaVersion": 1, + "modulePath": ":", + "flags": [], + "unknownFutureField": "some value" + } + """.trimIndent() + + // Should not throw — ignoreUnknownKeys = true in FeaturedManifestJson + val manifest = FeaturedManifestJson.decodeFromString(json) + assertEquals(1, manifest.schemaVersion) + assertEquals(":", manifest.modulePath) + } + + @Test + fun `unknown enum variant in FlagKind throws SerializationException`() { + val json = + """ + { + "schemaVersion": 1, + "modulePath": ":", + "flags": [ + { + "key": "f", + "propertyName": "f", + "kind": "UNKNOWN_KIND", + "valueType": "BOOLEAN", + "defaultValue": "false" + } + ] + } + """.trimIndent() + + // Silent skip of unknown enum variants would be a silent data-loss bug. + // SerializationException is the expected behavior — fail fast. + assertFailsWith { + FeaturedManifestJson.decodeFromString(json) + } + } + + @Test + fun `unknown enum variant in ValueType throws SerializationException`() { + val json = + """ + { + "schemaVersion": 1, + "modulePath": ":", + "flags": [ + { + "key": "f", + "propertyName": "f", + "kind": "LOCAL", + "valueType": "UNKNOWN_TYPE", + "defaultValue": "false" + } + ] + } + """.trimIndent() + + assertFailsWith { + FeaturedManifestJson.decodeFromString(json) + } + } + + @Test + fun `all flag fields are preserved in round-trip`() { + val flag = + FlagDescriptor( + key = "my_flag", + propertyName = "myFlag", + kind = FlagKind.REMOTE, + valueType = ValueType.STRING, + defaultValue = "hello world", + enumTypeFqn = null, + description = "A test flag", + category = "test", + expiresAt = "2026-12-31", + ) + val manifest = FeaturedManifest(schemaVersion = SCHEMA_VERSION, modulePath = ":app", flags = listOf(flag)) + + val decoded = + FeaturedManifestJson.decodeFromString( + FeaturedManifestJson.encodeToString(manifest), + ) + + val decodedFlag = decoded.flags.first() + assertEquals(flag.key, decodedFlag.key) + assertEquals(flag.propertyName, decodedFlag.propertyName) + assertEquals(flag.kind, decodedFlag.kind) + assertEquals(flag.valueType, decodedFlag.valueType) + assertEquals(flag.defaultValue, decodedFlag.defaultValue) + assertNull(decodedFlag.enumTypeFqn) + assertEquals("A test flag", decodedFlag.description) + assertEquals("test", decodedFlag.category) + assertEquals("2026-12-31", decodedFlag.expiresAt) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt new file mode 100644 index 0000000..cd56b1b --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/GenerateFeaturedManifestTaskRegistrationTest.kt @@ -0,0 +1,103 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import dev.androidbroadcast.featured.gradle.RESOLVE_FLAGS_TASK_NAME +import org.gradle.testfixtures.ProjectBuilder +import java.io.File +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class GenerateFeaturedManifestTaskRegistrationTest { + @Test + fun `plugin registers generateFeaturedManifest task`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + assertTrue( + project.tasks.names.contains(GENERATE_FEATURED_MANIFEST_TASK_NAME), + "Expected '$GENERATE_FEATURED_MANIFEST_TASK_NAME' task to be registered by the plugin", + ) + } + + @Test + fun `generateFeaturedManifest task is of correct type`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) + assertNotNull(task) + assertTrue( + task is GenerateFeaturedManifestTask, + "Expected task type GenerateFeaturedManifestTask but was ${task::class.simpleName}", + ) + } + + @Test + fun `generateFeaturedManifest task is in featured group`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) + assertNotNull(task) + assertEquals( + "featured", + task.group, + "Expected task group 'featured' but was '${task.group}'", + ) + } + + @Test + fun `generateFeaturedManifest task output path follows convention`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) as? GenerateFeaturedManifestTask + assertNotNull(task) + val outputPath = + task.outputFile + .get() + .asFile.path + assertTrue( + outputPath.endsWith("featured/featured-manifest.json"), + "Expected outputFile path to end with 'featured/featured-manifest.json', got: $outputPath", + ) + } + + @Test + fun `generate fails with IllegalArgumentException when modulePath does not start with colon`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val emptyFlags = File.createTempFile("flags", ".txt").apply { deleteOnExit() } + val task = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) as GenerateFeaturedManifestTask + task.modulePath.set("not-a-gradle-path") + task.flagsFile.set(emptyFlags) + + val ex = assertFailsWith { task.generate() } + assertTrue( + ex.message?.contains("not-a-gradle-path") == true, + "Expected error message to name the offending path, got: ${ex.message}", + ) + assertTrue( + ex.message?.contains(":") == true, + "Expected error message to mention the required ':' prefix, got: ${ex.message}", + ) + } + + @Test + fun `generateFeaturedManifest task depends on resolveFeatureFlags`() { + val project = ProjectBuilder.builder().build() + project.plugins.apply("dev.androidbroadcast.featured") + + val manifestTask = project.tasks.findByName(GENERATE_FEATURED_MANIFEST_TASK_NAME) + assertNotNull(manifestTask) + val resolveTask = project.tasks.findByName(RESOLVE_FLAGS_TASK_NAME) + assertNotNull(resolveTask) + assertTrue( + manifestTask.taskDependencies.getDependencies(manifestTask).contains(resolveTask), + "Expected '$GENERATE_FEATURED_MANIFEST_TASK_NAME' to depend on '$RESOLVE_FLAGS_TASK_NAME'", + ) + } +} diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt new file mode 100644 index 0000000..17fc588 --- /dev/null +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/TestFixtureSupport.kt @@ -0,0 +1,50 @@ +package dev.androidbroadcast.featured.gradle.manifest + +import java.io.File + +// Shared helpers for the manifest test suite. Each integration / TestKit test copies a +// pinned fixture directory into a per-test temp folder so that test runs do not pollute +// the source tree and remain isolated from each other. + +/** + * Copies the fixture directory named [fixtureName] from `featured-gradle-plugin/src/test/fixtures/` + * into [dest]. Non-file entries are skipped. The `.gitkeep` marker files used to keep otherwise + * empty fixture directories in git are filtered out — they are not part of the project under test. + */ +internal fun copyManifestFixture( + fixtureName: String, + dest: File, +) { + val source = fixtureSourceDir(fixtureName) + source + .walkTopDown() + .filter { it.isFile && it.name != ".gitkeep" } + .forEach { file -> + val target = dest.resolve(file.relativeTo(source)) + target.parentFile?.mkdirs() + file.copyTo(target, overwrite = true) + } +} + +private fun fixtureSourceDir(fixtureName: String): File { + val moduleDir = File(System.getProperty("user.dir")) + val candidate = moduleDir.resolve("src/test/fixtures/$fixtureName") + require(candidate.isDirectory) { + "Fixture directory not found at ${candidate.absolutePath}. " + + "Expected it relative to module project dir: ${moduleDir.absolutePath}" + } + return candidate +} + +/** + * Returns the Android SDK directory from `ANDROID_HOME` or `ANDROID_SDK_ROOT`, or null when + * neither is set or the path is not a directory. Used by integration tests that need an + * Android SDK to run the Android Gradle plugin; without it they skip via JUnit `Assume`. + */ +internal fun androidSdkDirOrNull(): File? { + val path = + System.getenv("ANDROID_HOME")?.takeIf { it.isNotBlank() } + ?: System.getenv("ANDROID_SDK_ROOT")?.takeIf { it.isNotBlank() } + ?: return null + return File(path).takeIf { it.isDirectory } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 03a5a0f..c3d1ca6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -18,6 +18,7 @@ firebaseBom = "34.11.0" junit = "4.13.2" kotlin = "2.3.10" kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.11.0" kover = "0.9.8" material = "1.13.0" mockk = "1.14.9" @@ -58,6 +59,7 @@ kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-c kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-playServices = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services", version.ref = "kotlinx-coroutines" } kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } +kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } firebase-analytics = { module = "com.google.firebase:firebase-analytics" } @@ -85,6 +87,7 @@ composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "composeMul composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } skie = { id = "co.touchlab.skie", version.ref = "skie" } spotless = { id = "com.diffplug.spotless", version.ref = "spotless" } From 6d2f87ca5248738c039c5458fce381987a030849 Mon Sep 17 00:00:00 2001 From: kirich1409 Date: Tue, 19 May 2026 08:03:37 +0300 Subject: [PATCH 2/2] Address review: normalize SDK path before writing local.properties MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cubic-dev-ai PR review (cf338244) flagged that FeaturedManifestIntegrationTest writes Android SDK path directly into local.properties via File.absolutePath, which on Windows yields raw backslashes — Java's .properties parser treats backslash as an escape character and would corrupt the path. Switch to File.invariantSeparatorsPath, which uniformly emits forward slashes. An identical pattern exists in the pre-existing FeaturedPluginIntegrationTest; that file is out of PR A scope and will be aligned in a separate cleanup PR. Co-Authored-By: Claude Opus 4.7 --- .../gradle/manifest/FeaturedManifestIntegrationTest.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt index 6a9c18b..be7c28e 100644 --- a/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt +++ b/featured-gradle-plugin/src/test/kotlin/dev/androidbroadcast/featured/gradle/manifest/FeaturedManifestIntegrationTest.kt @@ -33,7 +33,10 @@ class FeaturedManifestIntegrationTest { projectDir = tempFolder.newFolder("manifest-publish-project") copyManifestFixture("manifest-publish-project", projectDir) - projectDir.resolve("local.properties").writeText("sdk.dir=${sdkDir!!.absolutePath}\n") + // invariantSeparatorsPath replaces backslashes with forward slashes — Java's `.properties` + // parser treats backslashes as escape characters, so a raw Windows SDK path would corrupt + // local.properties. + projectDir.resolve("local.properties").writeText("sdk.dir=${sdkDir!!.invariantSeparatorsPath}\n") } @Test