From 83961f14ed113bdd90caf71c3f054c97d4dea312 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 23:33:25 -0400 Subject: [PATCH] refactor(build-logic): extract the KMP test-fixture generator into a convention plugin `libs/encryption/base58` and `libs/codes/kikcode` each carried a byte-identical copy of `GenerateTestFixtures`, differing only in the package the generated file is declared in, plus an identical block wiring the AGP lint tasks to depend on the generator. That copy-paste broke `code/cash` once already: #1288 added the lint wiring to kikcode, then #1289 introduced the same generator in base58 without it, and lint failed over an undeclared dependency on generated sources. Adding the source directory only carries the dependency to the Kotlin compile tasks -- lint reads the same directories straight off disk -- so the wiring is easy to forget and invisible until CI runs. Move the task into `build-logic` behind a `flipcash.kmp.test.fixtures` convention plugin. The plugin takes the package via a `testFixtures {}` extension, registers the generated directory on `commonTest`, and wires the lint dependency itself, so a module that applies it cannot omit it. Both modules drop from ~75 lines of build script to four. --- build-logic/convention/build.gradle.kts | 4 + .../kotlin/KmpTestFixturesConventionPlugin.kt | 53 +++++++++++++ .../testfixtures/GenerateTestFixtures.kt | 75 +++++++++++++++++++ .../testfixtures/TestFixturesExtension.kt | 22 ++++++ gradle/libs.versions.toml | 1 + libs/codes/kikcode/build.gradle.kts | 75 ++----------------- libs/encryption/base58/build.gradle.kts | 75 ++----------------- 7 files changed, 165 insertions(+), 140 deletions(-) create mode 100644 build-logic/convention/src/main/kotlin/KmpTestFixturesConventionPlugin.kt create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/testfixtures/GenerateTestFixtures.kt create mode 100644 build-logic/convention/src/main/kotlin/com/getcode/buildlogic/testfixtures/TestFixturesExtension.kt diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 4020568ef1..b510504fb5 100644 --- a/build-logic/convention/build.gradle.kts +++ b/build-logic/convention/build.gradle.kts @@ -45,5 +45,9 @@ gradlePlugin { id = "flipcash.kmp.library" implementationClass = "KmpLibraryConventionPlugin" } + register("kmpTestFixtures") { + id = "flipcash.kmp.test.fixtures" + implementationClass = "KmpTestFixturesConventionPlugin" + } } } diff --git a/build-logic/convention/src/main/kotlin/KmpTestFixturesConventionPlugin.kt b/build-logic/convention/src/main/kotlin/KmpTestFixturesConventionPlugin.kt new file mode 100644 index 0000000000..f49a9667b8 --- /dev/null +++ b/build-logic/convention/src/main/kotlin/KmpTestFixturesConventionPlugin.kt @@ -0,0 +1,53 @@ +import com.getcode.buildlogic.testfixtures.GenerateTestFixtures +import com.getcode.buildlogic.testfixtures.TestFixturesExtension +import org.gradle.api.Plugin +import org.gradle.api.Project +import org.gradle.kotlin.dsl.configure +import org.gradle.kotlin.dsl.create +import org.gradle.kotlin.dsl.register +import org.jetbrains.kotlin.gradle.dsl.KotlinMultiplatformExtension + +/** + * Compiles a KMP module's `src/commonTest/resources` into a generated `TestFixtures.kt` on + * `commonTest`, so the same fixtures are readable from every target (Kotlin/Native test binaries + * ship no resource bundle, so a resource-based loader only ever runs on the JVM). + * + * Usage in a module's `build.gradle.kts`: + * ``` + * plugins { + * alias(libs.plugins.flipcash.kmp.test.fixtures) + * } + * + * testFixtures { + * packageName = "com.getcode.vendor" + * } + * ``` + * + * The generated directory is registered on `commonTest` and the AGP lint tasks are made to depend + * on the generator -- adding the source directory only carries the dependency to the Kotlin compile + * tasks, while lint reads the same directories straight off disk and Gradle then fails the build + * over an undeclared dependency on generated sources. + */ +class KmpTestFixturesConventionPlugin : Plugin { + override fun apply(target: Project) { + with(target) { + val extension = extensions.create("testFixtures") + extension.fixtures.convention(layout.projectDirectory.dir("src/commonTest/resources")) + + val generateTestFixtures = tasks.register("generateTestFixtures") { + packageName.set(extension.packageName) + fixtures.set(extension.fixtures) + outputDirectory.set(layout.buildDirectory.dir("generated/testFixtures")) + } + + tasks.matching { it.name.startsWith("lint") || it.name.endsWith("LintModel") } + .configureEach { dependsOn(generateTestFixtures) } + + pluginManager.withPlugin("org.jetbrains.kotlin.multiplatform") { + extensions.configure { + sourceSets.named("commonTest") { kotlin.srcDir(generateTestFixtures) } + } + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/testfixtures/GenerateTestFixtures.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/testfixtures/GenerateTestFixtures.kt new file mode 100644 index 0000000000..1752fe39be --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/testfixtures/GenerateTestFixtures.kt @@ -0,0 +1,75 @@ +package com.getcode.buildlogic.testfixtures + +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.InputDirectory +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction + +/** + * Compiles the cross-platform fixtures into `commonTest` as Kotlin constants. + * + * The parity gate is only worth something if it runs on *both* platforms, and Kotlin/Native test + * binaries ship no resource bundle -- `NSBundle.pathForResource` finds nothing there, so a + * resource-based loader quietly only ever runs on the JVM. Generating a source file instead makes + * the same fixtures readable from every target with no platform code at all. + */ +abstract class GenerateTestFixtures : DefaultTask() { + + /** Package the generated `TestFixtures.kt` is declared in. */ + @get:Input + abstract val packageName: Property + + @get:InputDirectory + abstract val fixtures: DirectoryProperty + + @get:OutputDirectory + abstract val outputDirectory: DirectoryProperty + + @TaskAction + fun generate() { + val target = packageName.get() + val files = fixtures.get().asFile.listFiles().orEmpty().sortedBy { it.name } + val destination = outputDirectory.get().asFile + .resolve(target.replace('.', '/')) + .resolve("TestFixtures.kt") + destination.parentFile.mkdirs() + + destination.writeText( + buildString { + appendLine("package $target") + appendLine() + appendLine("// Generated from src/commonTest/resources -- do not edit.") + appendLine() + appendLine("private val FIXTURES: Map = mapOf(") + files.forEach { file -> + append(" \"").append(file.name).append("\" to \"") + append(file.readText().escapeForKotlin()) + appendLine("\",") + } + appendLine(")") + appendLine() + appendLine("/** Reads a fixture compiled in from `src/commonTest/resources/`. */") + appendLine("fun readTestResource(name: String): String =") + append(" requireNotNull(FIXTURES[name]) { \"unknown fixture '") + appendLine("\$name'\" }") + } + ) + } + + private fun String.escapeForKotlin(): String = buildString(length) { + this@escapeForKotlin.forEach { character -> + when (character) { + '\\' -> append("\\\\") + '"' -> append("\\\"") + '$' -> append("\\$") + '\n' -> append("\\n") + '\r' -> append("\\r") + '\t' -> append("\\t") + else -> append(character) + } + } + } +} diff --git a/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/testfixtures/TestFixturesExtension.kt b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/testfixtures/TestFixturesExtension.kt new file mode 100644 index 0000000000..5990f3f0be --- /dev/null +++ b/build-logic/convention/src/main/kotlin/com/getcode/buildlogic/testfixtures/TestFixturesExtension.kt @@ -0,0 +1,22 @@ +package com.getcode.buildlogic.testfixtures + +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property + +/** + * Configures [GenerateTestFixtures] for the module. + * + * ``` + * testFixtures { + * packageName = "com.getcode.vendor" + * } + * ``` + */ +abstract class TestFixturesExtension { + + /** Package the generated `TestFixtures.kt` is declared in. Required. */ + abstract val packageName: Property + + /** Directory of fixture files to compile in. Defaults to `src/commonTest/resources`. */ + abstract val fixtures: DirectoryProperty +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 7f28cf4e19..9d601cace6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -335,6 +335,7 @@ flipcash-android-library-compose = { id = "flipcash.android.library.compose" } flipcash-android-feature = { id = "flipcash.android.feature" } flipcash-android-ed25519-shadow = { id = "flipcash.android.ed25519.shadow" } flipcash-kmp-library = { id = "flipcash.kmp.library" } +flipcash-kmp-test-fixtures = { id = "flipcash.kmp.test.fixtures" } android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } diff --git a/libs/codes/kikcode/build.gradle.kts b/libs/codes/kikcode/build.gradle.kts index 35a1a56337..4dc7a31f19 100644 --- a/libs/codes/kikcode/build.gradle.kts +++ b/libs/codes/kikcode/build.gradle.kts @@ -1,79 +1,15 @@ plugins { kotlin("multiplatform") id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.test.fixtures) } -/** - * Compiles the cross-platform fixtures into `commonTest` as Kotlin constants. - * - * The parity gate is only worth something if it runs on *both* platforms, and Kotlin/Native test - * binaries ship no resource bundle -- `NSBundle.pathForResource` finds nothing there, so a - * resource-based loader quietly only ever runs on the JVM. Generating a source file instead makes - * the same fixtures readable from every target with no platform code at all. - */ -abstract class GenerateTestFixtures : DefaultTask() { - - @get:InputDirectory - abstract val fixtures: DirectoryProperty - - @get:OutputDirectory - abstract val outputDirectory: DirectoryProperty - - @TaskAction - fun generate() { - val files = fixtures.get().asFile.listFiles().orEmpty().sortedBy { it.name } - val destination = outputDirectory.get().asFile - .resolve("com/getcode/codes/kikcode/TestFixtures.kt") - destination.parentFile.mkdirs() - - destination.writeText( - buildString { - appendLine("package com.getcode.codes.kikcode") - appendLine() - appendLine("// Generated from src/commonTest/resources -- do not edit.") - appendLine() - appendLine("private val FIXTURES: Map = mapOf(") - files.forEach { file -> - append(" \"").append(file.name).append("\" to \"") - append(file.readText().escapeForKotlin()) - appendLine("\",") - } - appendLine(")") - appendLine() - appendLine("/** Reads a fixture compiled in from `src/commonTest/resources/`. */") - appendLine("fun readTestResource(name: String): String =") - append(" requireNotNull(FIXTURES[name]) { \"unknown fixture '") - appendLine("\$name'\" }") - } - ) - } - - private fun String.escapeForKotlin(): String = buildString(length) { - this@escapeForKotlin.forEach { character -> - when (character) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '$' -> append("\\$") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> append(character) - } - } - } +// Compiles `src/commonTest/resources` into a generated `TestFixtures.kt` on `commonTest`, readable +// from every target -- see the `flipcash.kmp.test.fixtures` convention plugin. +testFixtures { + packageName = "com.getcode.codes.kikcode" } -val generateTestFixtures = tasks.register("generateTestFixtures") { - fixtures.set(layout.projectDirectory.dir("src/commonTest/resources")) - outputDirectory.set(layout.buildDirectory.dir("generated/testFixtures")) -} - -// `srcDir(taskProvider)` below carries the task dependency to the Kotlin compile tasks only; AGP's -// lint tasks read the same source directories straight off disk, so Gradle fails the build over an -// undeclared dependency on the generated fixtures. Wire it up by hand. -tasks.matching { it.name.startsWith("lint") || it.name.endsWith("LintModel") } - .configureEach { dependsOn(generateTestFixtures) } - kotlin { android { namespace = "com.getcode.codes.kikcode" @@ -91,7 +27,6 @@ kotlin { // Pure Kotlin -- geometry + string building, no platform APIs. } commonTest { - kotlin.srcDir(generateTestFixtures) dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.serialization.json) diff --git a/libs/encryption/base58/build.gradle.kts b/libs/encryption/base58/build.gradle.kts index f54cabe36d..fd202f1619 100644 --- a/libs/encryption/base58/build.gradle.kts +++ b/libs/encryption/base58/build.gradle.kts @@ -1,79 +1,15 @@ plugins { kotlin("multiplatform") id("com.android.kotlin.multiplatform.library") + alias(libs.plugins.flipcash.kmp.test.fixtures) } -/** - * Compiles the cross-platform fixtures into `commonTest` as Kotlin constants. - * - * The parity gate is only worth something if it runs on *both* platforms, and Kotlin/Native test - * binaries ship no resource bundle -- `NSBundle.pathForResource` finds nothing there, so a - * resource-based loader quietly only ever runs on the JVM. Generating a source file instead makes - * the same fixtures readable from every target with no platform code at all. - */ -abstract class GenerateTestFixtures : DefaultTask() { - - @get:InputDirectory - abstract val fixtures: DirectoryProperty - - @get:OutputDirectory - abstract val outputDirectory: DirectoryProperty - - @TaskAction - fun generate() { - val files = fixtures.get().asFile.listFiles().orEmpty().sortedBy { it.name } - val destination = outputDirectory.get().asFile - .resolve("com/getcode/vendor/TestFixtures.kt") - destination.parentFile.mkdirs() - - destination.writeText( - buildString { - appendLine("package com.getcode.vendor") - appendLine() - appendLine("// Generated from src/commonTest/resources -- do not edit.") - appendLine() - appendLine("private val FIXTURES: Map = mapOf(") - files.forEach { file -> - append(" \"").append(file.name).append("\" to \"") - append(file.readText().escapeForKotlin()) - appendLine("\",") - } - appendLine(")") - appendLine() - appendLine("/** Reads a fixture compiled in from `src/commonTest/resources/`. */") - appendLine("fun readTestResource(name: String): String =") - append(" requireNotNull(FIXTURES[name]) { \"unknown fixture '") - appendLine("\$name'\" }") - } - ) - } - - private fun String.escapeForKotlin(): String = buildString(length) { - this@escapeForKotlin.forEach { character -> - when (character) { - '\\' -> append("\\\\") - '"' -> append("\\\"") - '$' -> append("\\$") - '\n' -> append("\\n") - '\r' -> append("\\r") - '\t' -> append("\\t") - else -> append(character) - } - } - } +// Compiles `src/commonTest/resources` into a generated `TestFixtures.kt` on `commonTest`, readable +// from every target -- see the `flipcash.kmp.test.fixtures` convention plugin. +testFixtures { + packageName = "com.getcode.vendor" } -val generateTestFixtures = tasks.register("generateTestFixtures") { - fixtures.set(layout.projectDirectory.dir("src/commonTest/resources")) - outputDirectory.set(layout.buildDirectory.dir("generated/testFixtures")) -} - -// `srcDir(taskProvider)` below carries the task dependency to the Kotlin compile tasks only; AGP's -// lint tasks read the same source directories straight off disk, so Gradle fails the build over an -// undeclared dependency on the generated fixtures. Wire it up by hand. -tasks.matching { it.name.startsWith("lint") || it.name.endsWith("LintModel") } - .configureEach { dependsOn(generateTestFixtures) } - kotlin { android { namespace = "com.getcode.encryption.base58" @@ -94,7 +30,6 @@ kotlin { // MessageDigest + BigInteger -- JDK only; no extra Gradle deps. } commonTest { - kotlin.srcDir(generateTestFixtures) dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.serialization.json)