diff --git a/build-logic/convention/build.gradle.kts b/build-logic/convention/build.gradle.kts index 4020568ef..b510504fb 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 000000000..f49a9667b --- /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 000000000..1752fe39b --- /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 000000000..5990f3f0b --- /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 7f28cf4e1..9d601cace 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 35a1a5633..4dc7a31f1 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 f54cabe36..fd202f161 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)