From 4400a78290e6d89d27c70b3d0445ae8750042f03 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 20 Aug 2026 16:58:03 -0400 Subject: [PATCH] test(base58): run the vector gate on Kotlin/Native, not just the JVM `:libs:encryption:base58`'s `iosTest` actual for `readTestResource` called `NSString.stringWithContentsOfFile` without opting in to `@kotlinx.cinterop.ExperimentalForeignApi`, which is a hard compile error on Kotlin/Native. `compileTestKotlinIosSimulatorArm64` had therefore never succeeded, so `Base58VectorTest` only ever ran on the JVM host despite the "asserted on both Android and iOS" claim in its own KDoc. Opting in would only have moved the failure to runtime: Kotlin/Native test binaries ship no resource bundle, so `NSBundle.pathForResource` returns null. Adopt the fix `:libs:codes:kikcode` already uses -- a `generateTestFixtures` task that compiles `src/commonTest/resources` into `commonTest` as Kotlin constants. That removes the need for the `expect`/`actual` `readTestResource` entirely and works on every target. Also move `Base58Test` out of `src/androidTest/`. Under the KMP Android plugin the host-test source set is `androidHostTest` (the module declares only `withHostTest {}`, and no device-test task exists), so `src/androidTest/` was not a source set and those 13 unit tests had never run either. base58's iOS parity was never actually uncovered -- it runs through the iOS repo's `FlipcashCoreVectors` via xcodebuild -- so this closes misleading dead source rather than a coverage hole. It does make the run matrix's parity claim true on both toolchains in this repo instead of only documented. Verified: `testAndroidHostTest` 14 tests green (1 vector + 13 unit), `iosSimulatorArm64Test` 1 test green. While in test-vectors/README.md, correct the base58 sync path -- the canonical JSON goes to `src/commonTest/resources/`, not the `src/test/resources/` the Regenerate section still named. --- libs/encryption/base58/build.gradle.kts | 68 ++++++++++++++++++- .../kotlin/com/getcode/vendor/Base58Test.kt | 0 .../getcode/vendor/TestResources.android.kt | 6 -- .../getcode/vendor/TestResources.android.kt | 6 -- .../com/getcode/vendor/TestResources.kt | 4 -- .../com/getcode/vendor/TestResources.ios.kt | 17 ----- test-vectors/README.md | 11 ++- 7 files changed, 76 insertions(+), 36 deletions(-) rename libs/encryption/base58/src/{androidTest => androidHostTest}/kotlin/com/getcode/vendor/Base58Test.kt (100%) delete mode 100644 libs/encryption/base58/src/androidHostTest/kotlin/com/getcode/vendor/TestResources.android.kt delete mode 100644 libs/encryption/base58/src/androidTest/kotlin/com/getcode/vendor/TestResources.android.kt delete mode 100644 libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/TestResources.kt delete mode 100644 libs/encryption/base58/src/iosTest/kotlin/com/getcode/vendor/TestResources.ios.kt diff --git a/libs/encryption/base58/build.gradle.kts b/libs/encryption/base58/build.gradle.kts index 7ecf2a4f19..20d1b69bc9 100644 --- a/libs/encryption/base58/build.gradle.kts +++ b/libs/encryption/base58/build.gradle.kts @@ -3,6 +3,71 @@ plugins { id("com.android.kotlin.multiplatform.library") } +/** + * 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) + } + } + } +} + +val generateTestFixtures = tasks.register("generateTestFixtures") { + fixtures.set(layout.projectDirectory.dir("src/commonTest/resources")) + outputDirectory.set(layout.buildDirectory.dir("generated/testFixtures")) +} + kotlin { android { namespace = "com.getcode.encryption.base58" @@ -10,7 +75,7 @@ kotlin { minSdk = 29 withHostTest {} } - + iosArm64() iosSimulatorArm64() iosX64() @@ -23,6 +88,7 @@ kotlin { // MessageDigest + BigInteger -- JDK only; no extra Gradle deps. } commonTest { + kotlin.srcDir(generateTestFixtures) dependencies { implementation(kotlin("test")) implementation(libs.kotlinx.serialization.json) diff --git a/libs/encryption/base58/src/androidTest/kotlin/com/getcode/vendor/Base58Test.kt b/libs/encryption/base58/src/androidHostTest/kotlin/com/getcode/vendor/Base58Test.kt similarity index 100% rename from libs/encryption/base58/src/androidTest/kotlin/com/getcode/vendor/Base58Test.kt rename to libs/encryption/base58/src/androidHostTest/kotlin/com/getcode/vendor/Base58Test.kt diff --git a/libs/encryption/base58/src/androidHostTest/kotlin/com/getcode/vendor/TestResources.android.kt b/libs/encryption/base58/src/androidHostTest/kotlin/com/getcode/vendor/TestResources.android.kt deleted file mode 100644 index 5365a32baf..0000000000 --- a/libs/encryption/base58/src/androidHostTest/kotlin/com/getcode/vendor/TestResources.android.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.getcode.vendor - -actual fun readTestResource(name: String): String = - checkNotNull(Thread.currentThread().contextClassLoader?.getResourceAsStream(name)) { - "Resource '$name' not found on classpath" - }.bufferedReader().use { it.readText() } diff --git a/libs/encryption/base58/src/androidTest/kotlin/com/getcode/vendor/TestResources.android.kt b/libs/encryption/base58/src/androidTest/kotlin/com/getcode/vendor/TestResources.android.kt deleted file mode 100644 index 5365a32baf..0000000000 --- a/libs/encryption/base58/src/androidTest/kotlin/com/getcode/vendor/TestResources.android.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.getcode.vendor - -actual fun readTestResource(name: String): String = - checkNotNull(Thread.currentThread().contextClassLoader?.getResourceAsStream(name)) { - "Resource '$name' not found on classpath" - }.bufferedReader().use { it.readText() } diff --git a/libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/TestResources.kt b/libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/TestResources.kt deleted file mode 100644 index 018aeb9205..0000000000 --- a/libs/encryption/base58/src/commonTest/kotlin/com/getcode/vendor/TestResources.kt +++ /dev/null @@ -1,4 +0,0 @@ -package com.getcode.vendor - -/** Reads a test resource file by name from `src/commonTest/resources/`. */ -expect fun readTestResource(name: String): String diff --git a/libs/encryption/base58/src/iosTest/kotlin/com/getcode/vendor/TestResources.ios.kt b/libs/encryption/base58/src/iosTest/kotlin/com/getcode/vendor/TestResources.ios.kt deleted file mode 100644 index 4640b6649d..0000000000 --- a/libs/encryption/base58/src/iosTest/kotlin/com/getcode/vendor/TestResources.ios.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.getcode.vendor - -import platform.Foundation.NSBundle -import platform.Foundation.NSString -import platform.Foundation.NSUTF8StringEncoding -import platform.Foundation.stringWithContentsOfFile - -actual fun readTestResource(name: String): String { - val nameWithoutExt = name.substringBeforeLast(".") - val ext = name.substringAfterLast(".", "") - val path = checkNotNull( - NSBundle.mainBundle.pathForResource(nameWithoutExt, ext) - ) { "Resource '$name' not found in bundle" } - return checkNotNull( - NSString.stringWithContentsOfFile(path, NSUTF8StringEncoding, null) - ) { "Failed to read resource '$name' at $path" } as String -} diff --git a/test-vectors/README.md b/test-vectors/README.md index 8fd82cc400..67c92a53c1 100644 --- a/test-vectors/README.md +++ b/test-vectors/README.md @@ -31,7 +31,7 @@ impl must reproduce the fixtures before the native duplicates are deleted. | Fixture | Android | iOS | |---|---|---| | `ed25519.json` | `:libs:encryption:ed25519` androidTest → `connectedAndroidTest` (device/emulator, JNI) — **green** | `CrossPlatformVectors` → `swift test` (host) — **green** | -| `base58.json` | `:libs:encryption:base58` → `test` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | +| `base58.json` | `:libs:encryption:base58` → `testAndroidHostTest` (host JVM) **and** `iosSimulatorArm64Test` (Kotlin/Native) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `slip10.json` | `:libs:encryption:mnemonic` androidTest → `connectedAndroidTest` (device, wordlist + JNI) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `curve.json` | `:libs:currency-math` androidTest → `connectedAndroidTest` (device, loads .bin tables) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | | `solana_message.json` | `:services:opencode` → `testDebugUnitTest` (host JVM) — **green** | `FlipcashCoreVectors` → xcodebuild on iOS Simulator — **green** | @@ -61,6 +61,13 @@ Bitcoin/Solana Base58 (`gen_base58.py`). Each: `bytes` (hex) → `base58`. Both `encode(bytes)==base58` and `decode(base58)==bytes`. Anchored to the Solana all-ones address (32 zero bytes → `111…1`) and cross-linked to the ed25519 public keys (real 32-byte Solana addresses). +Base58 is a KMP module, so its vector test is the one fixture asserted by **two toolchains inside this +repo**: the JVM host test and a Kotlin/Native `iosSimulatorArm64` run. Kotlin/Native test binaries ship +no resource bundle (`NSBundle.pathForResource` finds nothing), so the fixture is compiled into +`commonTest` as Kotlin constants by the `generateTestFixtures` task in the module's `build.gradle.kts` — +that is why there is no resource loader here. Keep dropping the canonical JSON into +`src/commonTest/resources/`; the task picks it up. (The same pattern is used by `:libs:codes:kikcode`.) + ## slip10 (`slip10.json`) — the C1 gate BIP39 + SLIP-0010 (ed25519) key derivation (`gen_slip10.py`): mnemonic + passphrase + path → @@ -150,7 +157,7 @@ python3 gen_compact_message.py > compact_message.json # intent-signing compact # Sync to Android per-module copies (from the repo root): cp test-vectors/ed25519.json libs/encryption/ed25519/src/androidTest/assets/ -cp test-vectors/base58.json libs/encryption/base58/src/test/resources/ +cp test-vectors/base58.json libs/encryption/base58/src/commonTest/resources/ cp test-vectors/slip10.json libs/encryption/mnemonic/src/androidTest/assets/ cp test-vectors/curve.json libs/currency-math/src/androidTest/assets/ cp test-vectors/curve_fractional.json libs/currency-math/src/androidTest/assets/