diff --git a/build-setup/src/main/kotlin/co/touchlab/skie/buildsetup/plugins/SkiePublishable.kt b/build-setup/src/main/kotlin/co/touchlab/skie/buildsetup/plugins/SkiePublishable.kt index 2a046efe..0695aedf 100644 --- a/build-setup/src/main/kotlin/co/touchlab/skie/buildsetup/plugins/SkiePublishable.kt +++ b/build-setup/src/main/kotlin/co/touchlab/skie/buildsetup/plugins/SkiePublishable.kt @@ -29,12 +29,26 @@ abstract class SkiePublishable : Plugin, HasMavenPublishPlugin, HasSign val extension = extensions.create("skiePublishing") + configureSmokeTestTmpRepository() configureMetadata(extension) configureKotlinJvmPublicationIfNeeded() configureSourcesJar(extension) configureJavadocJar() } + private fun Project.configureSmokeTestTmpRepository() { + val smokeTestTmpRepositoryPath: String? by this + smokeTestTmpRepositoryPath?.let { + publishing { + repositories { + maven { + url = uri(it) + name = "smokeTestTmpRepository" + } + } + } + } + } private fun Project.configureSigningIfNeeded() { val isRelease = !version.toString().endsWith("SNAPSHOT") val isPublishing = gradle.startParameter.taskNames.contains("publishToSonatype") diff --git a/test-runner/build.gradle.kts b/test-runner/build.gradle.kts new file mode 100644 index 00000000..cc0e543c --- /dev/null +++ b/test-runner/build.gradle.kts @@ -0,0 +1,37 @@ +import org.gradle.tooling.GradleConnector + +plugins { + kotlin("jvm") version "1.9.20" +} + +dependencies { + testImplementation(kotlin("test")) + testImplementation(gradleTestKit()) +} + +val smokeTestRepository = layout.buildDirectory.dir("smokeTestRepo") + +val publishSkieToTempMaven by tasks.registering { + doLast { + GradleConnector.newConnector() + .forProjectDirectory(rootDir.resolve("../SKIE")) + .connect() + .use { projectConnection -> + projectConnection.newBuild() + .forTasks("publishAllPublicationsToSmokeTestTmpRepositoryRepository") + .setStandardInput(System.`in`) + .setStandardOutput(System.out) + .setStandardError(System.err) + .addArguments("-PsmokeTestTmpRepositoryPath=${smokeTestRepository.get().asFile.absolutePath}") + .run() + } + } +} + +tasks.test { + useJUnitPlatform() + + dependsOn(publishSkieToTempMaven) + + systemProperty("smokeTestRepository", smokeTestRepository.get().asFile.absolutePath) +} diff --git a/test-runner/gradle b/test-runner/gradle new file mode 120000 index 00000000..03dbf5ae --- /dev/null +++ b/test-runner/gradle @@ -0,0 +1 @@ +../common-gradle/gradle \ No newline at end of file diff --git a/test-runner/gradle.properties b/test-runner/gradle.properties new file mode 120000 index 00000000..b55e5f45 --- /dev/null +++ b/test-runner/gradle.properties @@ -0,0 +1 @@ +../common-gradle/gradle.properties \ No newline at end of file diff --git a/test-runner/gradlew b/test-runner/gradlew new file mode 120000 index 00000000..b168a8a9 --- /dev/null +++ b/test-runner/gradlew @@ -0,0 +1 @@ +../common-gradle/gradlew \ No newline at end of file diff --git a/test-runner/gradlew.bat b/test-runner/gradlew.bat new file mode 120000 index 00000000..671d12c5 --- /dev/null +++ b/test-runner/gradlew.bat @@ -0,0 +1 @@ +../common-gradle/gradlew.bat \ No newline at end of file diff --git a/test-runner/settings.gradle.kts b/test-runner/settings.gradle.kts new file mode 100644 index 00000000..85f081c6 --- /dev/null +++ b/test-runner/settings.gradle.kts @@ -0,0 +1,8 @@ +rootProject.name = "test-runner" + +dependencyResolutionManagement { + repositories { + mavenCentral() + google() + } +} diff --git a/test-runner/src/test/kotlin/co/touchlab/skie/test/GradleTests.kt b/test-runner/src/test/kotlin/co/touchlab/skie/test/GradleTests.kt new file mode 100644 index 00000000..dc6fec02 --- /dev/null +++ b/test-runner/src/test/kotlin/co/touchlab/skie/test/GradleTests.kt @@ -0,0 +1,18 @@ +package co.touchlab.skie.test + +import kotlin.test.assertEquals + +class GradleTests { + + @Smoke + @SkieTest + @OnlyFor(configurations = [BuildConfiguration.Debug]) + @OnlyIos + fun `basic project`(target: BinaryTarget, config: BuildConfiguration, linkage: LinkMode) { + println("Hello $target, $config!") + assertEquals(BinaryTarget.IOS_X64, target) + assertEquals(BuildConfiguration.Debug, config) + // Run tests with param target + } + +} diff --git a/test-runner/src/test/kotlin/co/touchlab/skie/test/TestRunner.kt b/test-runner/src/test/kotlin/co/touchlab/skie/test/TestRunner.kt new file mode 100644 index 00000000..0ed795f0 --- /dev/null +++ b/test-runner/src/test/kotlin/co/touchlab/skie/test/TestRunner.kt @@ -0,0 +1,325 @@ +package co.touchlab.skie.test + +import org.junit.jupiter.api.TestTemplate +import org.junit.jupiter.api.extension.* +import org.junit.platform.commons.util.AnnotationUtils.isAnnotated +import org.junit.platform.commons.util.AnnotationUtils.findRepeatableAnnotations +import java.util.stream.Stream +import javax.print.attribute.standard.MediaSize.Other +import kotlin.properties.PropertyDelegateProvider +import kotlin.properties.ReadOnlyProperty +import kotlin.reflect.KClass +import kotlin.reflect.KProperty + +@TestTemplate +@ExtendWith(SkieTestRunner::class) +@ExtendWith(SmokeTestCondition::class) +@Target(AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class SkieTest { + +} + +enum class TestLevel { + Smoke, + Thorough, +} +// +// val linkMode: LinkMode = EnvDefaults.linkMode ?: LinkMode.Static, +// val buildConfiguration: BuildConfiguration = EnvDefaults.buildConfiguration ?: BuildConfiguration.Debug, +// val target: Target = EnvDefaults.target ?: Target.current, + +enum class BuildConfiguration { + Debug, + Release, +} + +enum class BinaryTarget(val kotlinName: String) { + IOS_ARM64("ios_arm64"), + IOS_X64("ios_x64"), + IOS_SIMULATOR_ARM64("ios_simulator_arm64"), + MACOS_ARM64("macos_arm64"), + MACOS_X64("macos_x64"), + ; + + val sdk: String + get() = when (this) { + IOS_ARM64 -> "iphoneos" + IOS_X64 -> "iphonesimulator" + IOS_SIMULATOR_ARM64 -> "iphonesimulator" + MACOS_ARM64 -> "macosx" + MACOS_X64 -> "macosx" + } + +// val targetTriple: TargetTriple +// get() = when (this) { +// IOS_ARM64 -> TargetTriple("arm64", "apple", "ios13.0", null) +// IOS_X64 -> TargetTriple("x86_64", "apple", "ios13.0", null) +// IOS_SIMULATOR_ARM64 -> TargetTriple("arm64", "apple", "ios13.0", "simulator") +// MACOS_ARM64 -> TargetTriple("arm64", "apple", "macos10.15", null) +// MACOS_X64 -> TargetTriple("x86_64", "apple", "macos10.15", null) +// } +// +// companion object { +// +// val current: BinaryTarget by lazy { +// val possibleTargets = mapOf( +// "arm64" to MACOS_ARM64, +// "x86_64" to MACOS_X64, +// ) +// val systemName = "uname -m".execute().stdOut.trim() +// +// possibleTargets[systemName] ?: error("Unsupported architecture: $systemName") +// } +// } +} + +enum class LinkMode { + Dynamic, + Static, +} + +data class SkieTestMatrix( + val axes: List>, +) { + val values = axes.associateBy { it.type } + + fun mapCells(transformCell: (List) -> T): List { + val cartesianProduct = axes.fold(listOf(emptyList())) { acc, axis -> + acc.flatMap { list -> + axis.values.map { element -> + list + AxisValue(axis.type, axis.name, element) + } + } + } + + return cartesianProduct.map(transformCell) + } + + data class Axis( + val type: Class, + val name: String, + val values: List, + ) { + companion object { + inline operator fun invoke(name: String, values: List): Axis { + return Axis( + type = T::class.java, + name = name, + values = values, + ) + } + } + } + + data class AxisValue( + val type: Class<*>, + val name: String, + val value: Any, + ) +} + +object SkieTestMatrixConfiguration { + + val testLevel: TestLevel by singleValue { TestLevel.Thorough } + val targets: List by multipleValues() + val configurations: List by multipleValues() + val linkModes: List by multipleValues() + + fun filteredAxes(filter: OnlyFor) = listOf( + SkieTestMatrix.Axis("Target", targets.intersectOrKeepIfEmpty(filter.targets)), + SkieTestMatrix.Axis("Configuration", configurations.intersectOrKeepIfEmpty(filter.configurations)), + SkieTestMatrix.Axis("Linkage", linkModes.intersectOrKeepIfEmpty(filter.linkModes)), + ).associateBy { it.type } + + + +// private operator fun > getValue(skieTestMatrix: SkieTestMatrix, property: KProperty<*>): E { +// // return System.getProperty(property.name, "").toBoolean() +// TODO() +// } + + private inline fun > singleValue(crossinline default: () -> E) = PropertyDelegateProvider { _: SkieTestMatrixConfiguration, property -> + val properties = System.getProperties() + val value = if (properties.containsKey(property.name)) { + val rawValue = properties.getProperty(property.name) + enumValueOf(rawValue) + } else { + default() + } + PropertyStorage(value) + } + + private inline fun > multipleValues(crossinline default: () -> List = { enumValues().toList() }) = PropertyDelegateProvider { _: SkieTestMatrixConfiguration, property -> + val properties = System.getProperties() + val values = if (properties.containsKey(property.name)) { + val rawValue = properties.getProperty(property.name) + rawValue.split(',').map { + enumValueOf(it) + } + + } else { + default() + } + PropertyStorage(values) + } + + private fun List.intersectOrKeepIfEmpty(other: Array): List { + return if (other.isNotEmpty()) { + val otherAsSet = other.toSet() + filter(otherAsSet::contains) + } else { + this + } + } + + private data class PropertyStorage(val value: T): ReadOnlyProperty { + override operator fun getValue(thisRef: SkieTestMatrixConfiguration, property: KProperty<*>): T = value + } +} + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +annotation class Smoke + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Smoke +annotation class SmokeOnly + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@Repeatable +annotation class OnlyFor( + val targets: Array = [], + val configurations: Array = [], + val linkModes: Array = [], +) + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@OnlyFor(targets = [BinaryTarget.IOS_SIMULATOR_ARM64, BinaryTarget.IOS_ARM64, BinaryTarget.IOS_X64]) +annotation class OnlyIos + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@OnlyFor(targets = [BinaryTarget.MACOS_ARM64, BinaryTarget.MACOS_X64]) +annotation class OnlyMacos + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@OnlyFor(configurations = [BuildConfiguration.Debug]) +annotation class OnlyDebug + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@OnlyFor(configurations = [BuildConfiguration.Release]) +annotation class OnlyRelease + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@OnlyFor(linkModes = [LinkMode.Static]) +annotation class OnlyStatic + +@Target(AnnotationTarget.CLASS, AnnotationTarget.FUNCTION) +@Retention(AnnotationRetention.RUNTIME) +@OnlyFor(linkModes = [LinkMode.Dynamic]) +annotation class OnlyDynamic + + + +class SmokeTestCondition: ExecutionCondition { + override fun evaluateExecutionCondition(context: ExtensionContext): ConditionEvaluationResult { + return when (SkieTestMatrixConfiguration.testLevel) { + TestLevel.Smoke -> if (isAnnotated(context.element, Smoke::class.java)) { + ConditionEvaluationResult.enabled("${context.element} is marked as @Smoke test") + } else { + ConditionEvaluationResult.disabled("${context.element} is not marked as @Smoke test") + } + TestLevel.Thorough -> if (isAnnotated(context.element, SmokeOnly::class.java)) { + ConditionEvaluationResult.disabled("${context.element} is marked as @SmokeOnly test") + } else { + ConditionEvaluationResult.enabled("${context.element} is not marked as @SmokeOnly test") + } + } + } +} + +class SkieMatrixExtension( + private val runValues: Map, Any>, +): ParameterResolver { + override fun supportsParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Boolean { + return runValues.containsKey(parameterContext.parameter.type) + } + + override fun resolveParameter(parameterContext: ParameterContext, extensionContext: ExtensionContext): Any { + return checkNotNull(runValues[parameterContext.parameter.type]) { + "Value for type ${parameterContext.parameter.type} not available! Check if `true` was returned for it in `supportsParameter`." + } + } +} + +class SkieTestRunner: TestTemplateInvocationContextProvider { + override fun supportsTestTemplate(context: ExtensionContext): Boolean { + return isAnnotated(context.testMethod, SkieTest::class.java) + } + + override fun provideTestTemplateInvocationContexts(context: ExtensionContext): Stream { + val testMethod = context.requiredTestMethod + val matrixFilter = findRepeatableAnnotations(testMethod, OnlyFor::class.java).fold(OnlyFor()) { acc, onlyFor -> + OnlyFor( + targets = acc.targets.intersectOrChoose(onlyFor.targets), + configurations = acc.configurations.intersectOrChoose(onlyFor.configurations), + linkModes = acc.linkModes.intersectOrChoose(onlyFor.linkModes), + ) + } + val filteredAxes = SkieTestMatrixConfiguration.filteredAxes(matrixFilter) + + val matrixAxes = testMethod.parameterTypes.map { requestedAxisType -> + checkNotNull(filteredAxes[requestedAxisType]) { + "Parameter of type $requestedAxisType not supported!" + } + } + + val matrix = SkieTestMatrix(axes = matrixAxes) + + return matrix.mapCells { + SkieTestMatrixContext(it) as TestTemplateInvocationContext + }.stream() + } + + private companion object { + + inline fun Array.intersectOrChoose(other: Array): Array { + return when { + this.isEmpty() -> other + other.isEmpty() -> this + else -> { + val otherAsSet = other.toSet() + filter(otherAsSet::contains).toTypedArray() + } + } + } + } +} + +class SkieTestMatrixContext( + private val axisValues: List, +): TestTemplateInvocationContext { + private val runValues = axisValues.associate { it.type to it.value } + + override fun getDisplayName(invocationIndex: Int): String { + val nameWithoutIndex = if (axisValues.isNotEmpty()) { + axisValues.joinToString(", ") { it.value.toString() } + } else { + "No arguments" + } + return "[$invocationIndex]: $nameWithoutIndex" + } + + override fun getAdditionalExtensions(): List { + return listOf( + SkieMatrixExtension(runValues), + ) + } +}