From 6b409d87f5390807bd69aaef75d0ce0e8914b3e4 Mon Sep 17 00:00:00 2001 From: Pavel Punegov Date: Thu, 18 Jan 2024 15:04:37 +0000 Subject: [PATCH] [K/N][test] Support library for running Kotlin/Native tests with XCTest This change adds a library with cinterop that has XCTest wrapper around Kotlin/Native tests (that are @kotlin.test.Test marked methods). This library can be compiled with either test code using the option `-produce test_bundle` to make a loadable test bundle or used inside the existing ObjC/Swift tests if compiled to a framework. The basic idea is to make XCTest be able to resolve separate test cases and correctly show them in test reports. This was achieved by wrapping test cases with dynamically created invocation methods. Test listeners are integrated with XCTest Observation to make it possible to have the same ability to report with GTest or TeamCity logging. Gradle build files use MPP Gradle plugin and use a bootstrap version of K/N. Property `kotlin.native.home` was moved to the kotlin-native subproject to not override this project's K/N distribution, that is being used by the KGP with the same property. This is a part of ^KT-58928 Merge-request: KT-MR-13268 Merged-by: Pavel Punegov --- gradle.properties | 1 - gradle/verification-metadata.xml | 1 + .../main/kotlin/org/jetbrains/kotlin/Utils.kt | 16 +- kotlin-native/gradle.properties | 3 + .../build.gradle.kts | 146 +++++++++++++ .../gradle.properties | 7 + .../src/nativeInterop/cinterop/XCTest.def | 9 + .../nativeMain/kotlin/NativeTestObserver.kt | 142 +++++++++++++ .../src/nativeMain/kotlin/NativeTestRunner.kt | 199 ++++++++++++++++++ .../src/nativeMain/kotlin/configuration.kt | 109 ++++++++++ .../kotlin/common-configuration.gradle.kts | 11 +- settings.gradle | 3 +- 12 files changed, 639 insertions(+), 8 deletions(-) create mode 100644 native/kotlin-test-native-xctest/build.gradle.kts create mode 100644 native/kotlin-test-native-xctest/gradle.properties create mode 100644 native/kotlin-test-native-xctest/src/nativeInterop/cinterop/XCTest.def create mode 100644 native/kotlin-test-native-xctest/src/nativeMain/kotlin/NativeTestObserver.kt create mode 100644 native/kotlin-test-native-xctest/src/nativeMain/kotlin/NativeTestRunner.kt create mode 100644 native/kotlin-test-native-xctest/src/nativeMain/kotlin/configuration.kt diff --git a/gradle.properties b/gradle.properties index ce006804960..6a515369fa8 100644 --- a/gradle.properties +++ b/gradle.properties @@ -72,7 +72,6 @@ kotlin.js.compiler.nowarn=true kotlin.internal.suppress.buildToolsApiVersionConsistencyChecks=true kotlin.native.enabled=false -kotlin.native.home=kotlin-native/dist org.gradle.vfs.watch=true diff --git a/gradle/verification-metadata.xml b/gradle/verification-metadata.xml index a5ba631039d..25217414b74 100644 --- a/gradle/verification-metadata.xml +++ b/gradle/verification-metadata.xml @@ -35,6 +35,7 @@ + diff --git a/kotlin-native/build-tools/src/main/kotlin/org/jetbrains/kotlin/Utils.kt b/kotlin-native/build-tools/src/main/kotlin/org/jetbrains/kotlin/Utils.kt index a4c974395f7..4ed2d5800c3 100644 --- a/kotlin-native/build-tools/src/main/kotlin/org/jetbrains/kotlin/Utils.kt +++ b/kotlin-native/build-tools/src/main/kotlin/org/jetbrains/kotlin/Utils.kt @@ -58,10 +58,10 @@ val validPropertiesNames = listOf( ) val Project.kotlinNativeDist - get() = rootProject.currentKotlinNativeDist + get() = rootProject.project(":kotlin-native").currentKotlinNativeDist val Project.currentKotlinNativeDist - get() = file(validPropertiesNames.firstOrNull { hasProperty(it) }?.let { findProperty(it) } ?: "dist") + get() = rootProject.file(validPropertiesNames.firstOrNull { hasProperty(it) }?.let { findProperty(it) } ?: "dist") val kotlinNativeHome get() = validPropertiesNames.mapNotNull(System::getProperty).first() @@ -164,7 +164,7 @@ fun Project.dependsOnDist(taskName: String) { project.tasks.getByName(taskName).dependsOnDist() } -fun TaskProvider.dependsOnDist() { +fun TaskProvider.dependsOnDist() { configure { dependsOnDist() } @@ -199,6 +199,10 @@ private fun Project.isCrossDist(target: KonanTarget): Boolean { fun Task.dependsOnDist() { val target = project.testTarget + dependsOnDist(target) +} + +fun Task.dependsOnDist(target: KonanTarget) { if (project.isDefaultNativeHome) { dependsOn(":kotlin-native:dist") if (target != HostManager.host) { @@ -227,6 +231,12 @@ fun Task.dependsOnCrossDist(target: KonanTarget) { } } +fun Task.dependsOnPlatformLibs(target: KonanTarget) { + if (project.isDefaultNativeHome) { + dependsOn(":kotlin-native:${target.name}PlatformLibs") + } +} + fun Task.konanOldPluginTaskDependenciesWalker(index: Int = 0, walker: Task.(Int) -> Unit) { walker(index + 1) dependsOn.forEach { diff --git a/kotlin-native/gradle.properties b/kotlin-native/gradle.properties index 359b19f4f57..450e1948cdc 100644 --- a/kotlin-native/gradle.properties +++ b/kotlin-native/gradle.properties @@ -32,3 +32,6 @@ slackApiVersion=1.2.0 ktorVersion=1.2.1 shadowVersion=8.1.1 metadataVersion=0.0.1-dev-10 + +# Default location of dist dir relative to the root project +kotlin.native.home=kotlin-native/dist \ No newline at end of file diff --git a/native/kotlin-test-native-xctest/build.gradle.kts b/native/kotlin-test-native-xctest/build.gradle.kts new file mode 100644 index 00000000000..a48c8e85cc3 --- /dev/null +++ b/native/kotlin-test-native-xctest/build.gradle.kts @@ -0,0 +1,146 @@ +import org.jetbrains.kotlin.gradle.plugin.KotlinPlatformType +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinNativeTarget +import org.jetbrains.kotlin.gradle.plugin.mpp.KotlinUsages +import org.jetbrains.kotlin.gradle.tasks.CInteropProcess +import org.jetbrains.kotlin.konan.target.* +import java.io.ByteArrayOutputStream +import java.nio.file.Paths + +description = "XCTest wrapper of Native kotlin.test" + +plugins { + kotlin("multiplatform") +} + +/** + * Path to the target SDK platform. + * + * By default, K/N includes only SDK frameworks as platform libs. + * It's required to get the Library frameworks path where the `XCTest.framework` is located. + * Consider making XCTest a platform lib with KT-61709. + */ +fun targetPlatform(target: String): String { + val out = ByteArrayOutputStream() + val result = project.exec { + executable = "/usr/bin/xcrun" + args = listOf("--sdk", target, "--show-sdk-platform-path") + standardOutput = out + } + check(result.exitValue == 0) { + "xcrun failed with ${result.exitValue}. See the output: $out" + } + + return out.toString().trim() +} + +/** + * Returns a path to the Xcode developer frameworks location based on the specified KonanTarget. + */ +fun KonanTarget.developerFrameworkPath(): String { + val platform = when (this) { + KonanTarget.MACOS_ARM64, KonanTarget.MACOS_X64 -> "macosx" + KonanTarget.IOS_SIMULATOR_ARM64, KonanTarget.IOS_X64 -> "iphonesimulator" + KonanTarget.IOS_ARM64 -> "iphoneos" + else -> error("Target $this is not supported here") + } + + return targetPlatform(platform) + "/Developer/Library/Frameworks/" +} + +/** + * Registers a task to copy the XCTest framework to the build directory for the specified KonanTarget. + * + * @param target The KonanTarget for which the copy framework task should be registered. + * @return The TaskProvider representing the registered copy framework task. + */ +fun registerCopyFrameworkTask(target: KonanTarget): TaskProvider = + tasks.register("${target}FrameworkCopy") { + val devFrameworkPath = Paths.get(target.developerFrameworkPath()) + check(devFrameworkPath.toFile().exists()) { + "Developer frameworks path wasn't found at $devFrameworkPath. Check configuration and Xcode installation" + } + + from(devFrameworkPath.resolve("XCTest.framework")) + into(layout.buildDirectory.dir("$target/Frameworks/XCTest.framework")) + } + +val nativeTargets = mutableListOf() + +if (HostManager.hostIsMac) { + kotlin { + with(nativeTargets) { + add(macosX64()) + add(macosArm64()) + add(iosX64()) + add(iosArm64()) + add(iosSimulatorArm64()) + + forEach { + val copyTask = registerCopyFrameworkTask(it.konanTarget) + it.compilations.all { + cinterops { + register("XCTest") { + compilerOpts( + "-iframework", project.layout.buildDirectory + .dir("$konanTarget/Frameworks") + .get() + .asFile + .absolutePath + ) + // cinterop task should depend on the framework copy task + tasks.named(interopProcessingTaskName).configure { + dependsOn(copyTask) + } + } + } + } + } + } + sourceSets.all { + languageSettings.apply { + // Oh, yeah! So much experimental, so wow! + optIn("kotlinx.cinterop.BetaInteropApi") + optIn("kotlinx.cinterop.ExperimentalForeignApi") + optIn("kotlin.experimental.ExperimentalNativeApi") + } + } + } +} + +val kotlinTestNativeXCTest by configurations.creating { + attributes { + attribute(Usage.USAGE_ATTRIBUTE, objects.named(KotlinUsages.KOTLIN_API)) + attribute(KotlinPlatformType.attribute, KotlinPlatformType.native) + } +} + +nativeTargets.forEach { target -> + val targetName = target.konanTarget.name + val mainCompilation = target.compilations.getByName("main") + val outputKlibTask = mainCompilation.compileTaskProvider + + @Suppress("UNCHECKED_CAST") + val cinteropKlibTask = tasks.named( + mainCompilation.cinterops + .getByName("XCTest") + .interopProcessingTaskName + ) as? TaskProvider ?: error("Unable to get CInteropProcess task provider") + + val frameworkCopyTask = tasks.named("${targetName}FrameworkCopy") + + artifacts { + add(kotlinTestNativeXCTest.name, outputKlibTask.flatMap { it.outputFile }) { + classifier = targetName + builtBy(outputKlibTask) + } + add(kotlinTestNativeXCTest.name, cinteropKlibTask.flatMap { it.outputFileProvider }) { + classifier = targetName + builtBy(cinteropKlibTask) + } + // Add a path to a directory that contains copied framework to share it with test infrastructure + add(kotlinTestNativeXCTest.name, frameworkCopyTask.map { it.destinationDir.parentFile }) { + classifier = "${targetName}Frameworks" + builtBy(frameworkCopyTask) + } + } +} \ No newline at end of file diff --git a/native/kotlin-test-native-xctest/gradle.properties b/native/kotlin-test-native-xctest/gradle.properties new file mode 100644 index 00000000000..1595e6d3770 --- /dev/null +++ b/native/kotlin-test-native-xctest/gradle.properties @@ -0,0 +1,7 @@ +# Disable commonizer. It writes its data to the distribution, that's not desirable during the build +kotlin.mpp.enableNativeDistributionCommonizationCache=false +kotlin.mpp.enableCInteropCommonization=false +kotlin.mpp.enableCInteropCommonization.nowarn=true + +# No need to do any publishing +kotlin.internal.mpp.createDefaultMultiplatformPublications=false \ No newline at end of file diff --git a/native/kotlin-test-native-xctest/src/nativeInterop/cinterop/XCTest.def b/native/kotlin-test-native-xctest/src/nativeInterop/cinterop/XCTest.def new file mode 100644 index 00000000000..8c8f2ada7e2 --- /dev/null +++ b/native/kotlin-test-native-xctest/src/nativeInterop/cinterop/XCTest.def @@ -0,0 +1,9 @@ +depends = Foundation darwin posix +language = Objective-C +package = platform.XCTest +modules = XCTest + +compilerOpts = -framework XCTest +linkerOpts = -framework XCTest + +foreignExceptionMode = objc-wrap diff --git a/native/kotlin-test-native-xctest/src/nativeMain/kotlin/NativeTestObserver.kt b/native/kotlin-test-native-xctest/src/nativeMain/kotlin/NativeTestObserver.kt new file mode 100644 index 00000000000..f96c2e727d8 --- /dev/null +++ b/native/kotlin-test-native-xctest/src/nativeMain/kotlin/NativeTestObserver.kt @@ -0,0 +1,142 @@ +/* + * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +import kotlin.native.internal.test.* +import kotlin.time.* +import kotlin.time.Duration +import kotlin.time.DurationUnit +import platform.Foundation.NSError +import platform.darwin.NSObject +import platform.XCTest.* + +/** + * Test execution observation. + * + * This is a bridge between XCTest execution and reporting that brings an ability to get results test-by-test. + * It logs tests and notifies listeners set with [testSettings]. + * See also [XCTestObservation on Apple documentation](https://developer.apple.com/documentation/xctest/xctestobservation) + * + * @see TestSettings + */ +internal class NativeTestObserver(private val testSettings: TestSettings) : NSObject(), XCTestObservationProtocol { + private val listeners = testSettings.listeners + private val logger = testSettings.logger + + private inline fun sendToListeners(event: TestListener.() -> Unit) { + logger.event() + listeners.forEach(event) + } + + private fun XCTest.getTestDuration(): Duration = + testRun?.totalDuration + ?.toDuration(DurationUnit.SECONDS) + ?: Duration.ZERO + + /** + * Failed test case execution. + * + * Records test failures sending them to test listeners. + */ + override fun testCase(testCase: XCTestCase, didRecordIssue: XCTIssue) { + if (testCase is XCTestCaseWrapper) { + val duration = testCase.getTestDuration() + val error = didRecordIssue.associatedError as NSError + val throwable = if (error is NSErrorWithKotlinException) { + error.kotlinException + } else { + Throwable(didRecordIssue.compactDescription) + } + sendToListeners { fail(testCase.testCase, throwable, duration.inWholeMilliseconds) } + } + } + + /** + * Records expected failures as failed test as soon as such expectations should be processed in the test. + */ + override fun testCase(testCase: XCTestCase, didRecordExpectedFailure: XCTExpectedFailure) { + logger.log("TestCase: $testCase got expected failure: ${didRecordExpectedFailure.failureReason}") + this.testCase(testCase, didRecordExpectedFailure.issue) + } + + /** + * Test case finish notification. + * Both successful and failed executions get this notification. + */ + override fun testCaseDidFinish(testCase: XCTestCase) { + val duration = testCase.getTestDuration() + if (testCase.testRun?.hasSucceeded == true) { + if (testCase is XCTestCaseWrapper) { + val test = testCase.testCase + if (!test.ignored) sendToListeners { pass(test, duration.inWholeMilliseconds) } + } + } + } + + /** + * Test case start notification. + */ + override fun testCaseWillStart(testCase: XCTestCase) { + if (testCase is XCTestCaseWrapper) { + val test = testCase.testCase + if (test.ignored) { + sendToListeners { ignore(test) } + } else { + sendToListeners { start(test) } + } + } + } + + /** + * Test suite failure notification. + * + * Logs the failure of the test suite execution. + */ + override fun testSuite(testSuite: XCTestSuite, didRecordIssue: XCTIssue) { + logger.log("TestSuite ${testSuite.name} recorded issue: ${didRecordIssue.compactDescription}") + } + + /** + * Test suite expected failure. + * + * Logs the failure of the test suite execution. + * Treat expected failures as ordinary unexpected one. + */ + override fun testSuite(testSuite: XCTestSuite, didRecordExpectedFailure: XCTExpectedFailure) { + logger.log("TestSuite ${testSuite.name} got expected failure: ${didRecordExpectedFailure.failureReason}") + this.testSuite(testSuite, didRecordExpectedFailure.issue) + } + + /** + * Test suite finish notification. + */ + override fun testSuiteDidFinish(testSuite: XCTestSuite) { + val duration = testSuite.getTestDuration().inWholeMilliseconds + if (testSuite is XCTestSuiteWrapper) { + sendToListeners { finishSuite(testSuite.testSuite, duration) } + } else if (testSuite.name == TOP_LEVEL_SUITE) { + sendToListeners { + finishIteration(testSettings, 0, duration) // test iterations are not supported + finishTesting(testSettings, duration) + } + } + } + + /** + * Test suite start notification. + */ + override fun testSuiteWillStart(testSuite: XCTestSuite) { + if (testSuite is XCTestSuiteWrapper) { + sendToListeners { startSuite(testSuite.testSuite) } + } else if (testSuite.name == TOP_LEVEL_SUITE) { + sendToListeners { + startTesting(testSettings) + startIteration(testSettings, 0, testSettings.testSuites) // test iterations are not supported + } + } + } + + override fun debugDescription() = "Native test listener with test settings $testSettings" +} \ No newline at end of file diff --git a/native/kotlin-test-native-xctest/src/nativeMain/kotlin/NativeTestRunner.kt b/native/kotlin-test-native-xctest/src/nativeMain/kotlin/NativeTestRunner.kt new file mode 100644 index 00000000000..881fe3f659a --- /dev/null +++ b/native/kotlin-test-native-xctest/src/nativeMain/kotlin/NativeTestRunner.kt @@ -0,0 +1,199 @@ +/* + * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +import kotlinx.cinterop.* +import kotlin.native.internal.test.* +import platform.Foundation.* +import platform.Foundation.NSError +import platform.Foundation.NSInvocation +import platform.Foundation.NSString +import platform.Foundation.NSMethodSignature +import platform.UniformTypeIdentifiers.UTTypeSourceCode +import platform.XCTest.* +import platform.objc.* + +/** + * An XCTest equivalent of the K/N TestCase. + * + * Wraps the [TestCase] that runs it with a special bridge method created by adding it to a class. + * The idea is to make XCTest invoke them by the created invocation and show the selector as a test name. + * This selector is created as `class.method` that is than naturally represented in XCTest reports including XCode. + */ +@ExportObjCClass(name = "KotlinNativeTest") +class XCTestCaseWrapper(invocation: NSInvocation, val testCase: TestCase) : XCTestCase(invocation) { + // Sets XCTest to continue running after failure to match Kotlin Test + override fun continueAfterFailure(): Boolean = true + + private val ignored = testCase.ignored || testCase.suite.ignored + + private val testName = testCase.fullName + + fun run() { + if (ignored) { + // FIXME: to skip the test XCTSkip() should be used. + // But it is not possible to do that due to the KT-43719 and not implemented exception importing. + // For example, _XCTSkipHandler(testName, 0U, "Test $testName is ignored") fails with 'Uncaught Kotlin exception'. + // So, just don't run the test. It will be seen as passed in XCode, but K/N TestListener correctly processes that. + return + } + try { + testCase.doRun() + } catch (throwable: Throwable) { + val stackTrace = throwable.getStackTrace() + val failedStackLine = stackTrace.first { + // try to filter out kotlin.Exceptions and kotlin.test.Assertion inits to poin to the failed stack and line + !it.contains("kfun:kotlin.") + } + // Find path and line number to create source location + val matchResult = Regex("^\\d+ +.* \\((.*):(\\d+):.*\\)$").find(failedStackLine) + val sourceLocation = if (matchResult != null) { + val (file, line) = matchResult.destructured + XCTSourceCodeLocation(file, line.toLong()) + } else { + // No debug info to get the path. Still have to record location + XCTSourceCodeLocation(testCase.suite.name, 0L) + } + + // Make a stacktrace attachment, encoding it as source code. + // This makes it appear as an attachment in the XCode test results for the failed test. + @Suppress("CAST_NEVER_SUCCEEDS") + val stackAsPayload = (stackTrace.joinToString("\n") as? NSString)?.dataUsingEncoding(NSUTF8StringEncoding) + val stackTraceAttachment = XCTAttachment.attachmentWithUniformTypeIdentifier( + identifier = UTTypeSourceCode.identifier, + name = "Kotlin stacktrace (full)", + payload = stackAsPayload, + userInfo = null + ) + + val type = when (throwable) { + is AssertionError -> XCTIssueTypeAssertionFailure + else -> XCTIssueTypeUncaughtException + } + + // Finally, create and record an issue with all gathered data + val issue = XCTIssue( + type = type, + compactDescription = "$throwable in $testName", + detailedDescription = buildString { + appendLine("Test '$testName' from '${testCase.suite.name}' failed with $throwable") + throwable.cause?.let { appendLine("(caused by ${throwable.cause})") } + }, + sourceCodeContext = XCTSourceCodeContext( + callStackAddresses = throwable.getStackTraceAddresses(), + location = sourceLocation + ), + // pass the error through the XCTest to the NativeTestObserver + associatedError = NSErrorWithKotlinException(throwable), + attachments = listOf(stackTraceAttachment) + ) + testRun?.recordIssue(issue) ?: error("TestRun for the test $testName not found") + } + } + + override fun setUp() { + if (!ignored) testCase.doBefore() + } + + override fun tearDown() { + if (!ignored) testCase.doAfter() + } + + override fun description(): String = buildString { + append(testName) + if (ignored) append("(ignored)") + } + + override fun name() = testName + + companion object : XCTestCaseMeta() { + /** + * This method is invoked by the XCTest when it discovered XCTestCase instance + * that contains test method. + * + * This method should not be called with the current idea and assumptions. + */ + override fun testCaseWithInvocation(invocation: NSInvocation?): XCTestCase { + error( + """ + This should not happen by default. + Got invocation: ${invocation?.description} + with selector @sel(${NSStringFromSelector(invocation?.selector)}) + """.trimIndent() + ) + } + + /** + * Creates and adds method to the metaclass with implementation block + * that gets an XCTestCase instance as self to be run. + */ + private fun createRunMethod(selector: SEL) { + val result = class_addMethod( + cls = this.`class`(), + name = selector, + imp = imp_implementationWithBlock(this::runner), + types = "v@:" // Obj-C type encodings: v (returns void), @ (id self), : (SEL sel) + ) + check(result) { + "Internal error: was unable to add method with selector $selector" + } + } + + @Suppress("UNUSED_PARAMETER") + private fun runner(testCaseWrapper: XCTestCaseWrapper, sel: SEL) = testCaseWrapper.run() + + /** + * Creates Test invocations for each test method to make them resolvable by the XCTest machinery. + * + * For each kotlin-test's test case make an NSInvocation with an appropriate selector that represents test name: + * - Create NSSelector from the given test name. + * - Create implementation method with block for runner method. This method accepts the instance of the XCTestCaseWrapper + * to run the actual test code. + * - Create NSInvocation from the selector using NSMethodSignature. + * + * Then this NSInvocation should be used to create an instance of XCTestCaseWrapper that implements XCTestCase. + * When XCTest runs this instance, it invokes this invocation that passes Wrapper's instance to the `runner(...)` method. + * + * @see createRunMethod + * @see runner + * @see XCTestCaseWrapper.run + */ + override fun testInvocations(): List = testMethodsNames.map { + val selector = NSSelectorFromString(it) + createRunMethod(selector) + this.instanceMethodSignatureForSelector(selector)?.let { signature -> + @Suppress("CAST_NEVER_SUCCEEDS") + val invocation = NSInvocation.invocationWithMethodSignature(signature as NSMethodSignature) + invocation.setSelector(selector) + invocation + } ?: error("Was unable to create NSInvocation for method $it") + } + } +} + +private typealias SEL = COpaquePointer? + +/** + * This is a NSError-wrapper of Kotlin exception used to pass it through the XCTIssue + * to the XCTestObservation protocol implementation [NativeTestObserver]. + * See [NativeTestObserver.testCase] for the usage. + */ +internal class NSErrorWithKotlinException(val kotlinException: Throwable) : NSError(NSCocoaErrorDomain, NSValidationErrorMinimum, null) + +/** + * XCTest equivalent of K/N TestSuite. + */ +class XCTestSuiteWrapper(val testSuite: TestSuite) : XCTestSuite(testSuite.name) { + private val ignoredSuite: Boolean + get() = testSuite.ignored || testSuite.testCases.all { it.value.ignored } + + override fun setUp() { + if (!ignoredSuite) testSuite.doBeforeClass() + } + + override fun tearDown() { + if (!ignoredSuite) testSuite.doAfterClass() + } +} diff --git a/native/kotlin-test-native-xctest/src/nativeMain/kotlin/configuration.kt b/native/kotlin-test-native-xctest/src/nativeMain/kotlin/configuration.kt new file mode 100644 index 00000000000..879435f0dc5 --- /dev/null +++ b/native/kotlin-test-native-xctest/src/nativeMain/kotlin/configuration.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2010-2023 JetBrains s.r.o. and Kotlin Programming Language contributors. + * Use of this source code is governed by the Apache 2.0 license that can be found in the license/LICENSE.txt file. + */ +@file:Suppress("INVISIBLE_REFERENCE", "INVISIBLE_MEMBER") + +import kotlinx.cinterop.* +import kotlin.native.internal.test.* +import platform.Foundation.NSInvocation +import platform.Foundation.NSBundle +import platform.Foundation.NSStringFromSelector +import platform.XCTest.* +import platform.objc.* + +// Top level suite name used to hold all Native tests +internal const val TOP_LEVEL_SUITE = "Kotlin/Native test suite" + +// Name of the key that contains arguments used to set [TestSettings] +private const val TEST_ARGUMENTS_KEY = "KotlinNativeTestArgs" + +/** + * Stores current settings with the filtered test suites, loggers, and listeners. + * Test settings should be initialized by the setup method. + */ +private lateinit var testSettings: TestSettings + +/** + * This is an entry-point of XCTestSuites and XCTestCases generation. + * Function returns the XCTest's top level TestSuite that holds all the test cases + * with K/N tests. + * This test suite can be run by either native launcher compiled to bundle or + * by the other test suite (e.g. compiled as a framework). + */ +@Suppress("unused") +@kotlin.native.internal.ExportForCppRuntime("Konan_create_testSuite") +internal fun setupXCTestSuite(): XCTestSuite { + val nativeTestSuite = XCTestSuite.testSuiteWithName(TOP_LEVEL_SUITE) + + // Initialize settings with the given args + val args = testArguments(TEST_ARGUMENTS_KEY) + testSettings = TestProcessor(GeneratedSuites.suites, args).process() + + check(::testSettings.isInitialized) { + "Test settings wasn't set. Check provided arguments and test suites" + } + + // Set test observer that will log test execution + XCTestObservationCenter.sharedTestObservationCenter.addTestObserver(NativeTestObserver(testSettings)) + + if (testSettings.runTests == true) { + // Generate and add tests to the main suite + testSettings.testSuites.generate().forEach { + nativeTestSuite.addTest(it) + } + + // Tests created (self-check) + @Suppress("UNCHECKED_CAST") + check(testSettings.testSuites.size == (nativeTestSuite.tests as List).size) { + "The amount of generated XCTest suites should be equal to Kotlin test suites" + } + } + + return nativeTestSuite +} + +/** + * Gets test arguments from the Info.plist using the provided key to create test settings. + * + * @param key a key used in the `Info.plist` file to pass test arguments + */ +private fun testArguments(key: String): Array { + // As we don't know which bundle we are, iterate through all of them + val plistTestArgs = NSBundle.allBundles + .mapNotNull { + (it as? NSBundle)?.infoDictionary?.get(key) + }.singleOrNull() as? String + return plistTestArgs?.split(" ") + ?.toTypedArray() + ?: emptyArray() +} + +internal val testMethodsNames: List + get() = testSettings.testSuites.toList() + .flatMap { testSuite -> + testSuite.testCases.values.map { it.fullName } + } + +internal val TestCase.fullName get() = "${suite.name}.$name" + +private fun Collection.generate(): List { + val testInvocations = XCTestCaseWrapper.testInvocations() + return this.map { suite -> + val xcSuite = XCTestSuiteWrapper(suite) + suite.testCases.values.map { testCase -> + // Produce test case wrapper from the test invocation + testInvocations.filter { + it.selectorString() == testCase.fullName + }.map { invocation -> + XCTestCaseWrapper(invocation, testCase) + }.single() + }.forEach { + // add test to its test suite wrappper + xcSuite.addTest(it) + } + xcSuite + } +} + +private fun NSInvocation.selectorString() = NSStringFromSelector(selector) diff --git a/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/common-configuration.gradle.kts b/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/common-configuration.gradle.kts index 50f1fc44583..b0b478beaa0 100644 --- a/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/common-configuration.gradle.kts +++ b/repo/gradle-build-conventions/buildsrc-compat/src/main/kotlin/common-configuration.gradle.kts @@ -164,9 +164,14 @@ fun Project.configureKotlinCompilationOptions() { val useAbsolutePathsInKlib = kotlinBuildProperties.getBoolean("kotlin.build.use.absolute.paths.in.klib") // Workaround to avoid remote build cache misses due to absolute paths in relativePathBaseArg - doFirst { - if (!useAbsolutePathsInKlib) { - kotlinOptions.freeCompilerArgs += "-Xklib-relative-path-base=${layout.buildDirectory.get().asFile},${layout.projectDirectory.asFile},$rootDir" + // This is a workaround for KT-50876, but with no clear explanation why doFirst is used. + // However, KGP with Native targets is used in the native-xctest project, and this code fails with + // The value for property 'freeCompilerArgs' is final and cannot be changed any further. + if (project.path != ":native:kotlin-test-native-xctest") { + doFirst { + if (!useAbsolutePathsInKlib) { + kotlinOptions.freeCompilerArgs += "-Xklib-relative-path-base=${layout.buildDirectory.get().asFile},${layout.projectDirectory.asFile},$rootDir" + } } } } diff --git a/settings.gradle b/settings.gradle index 9b8ce3a3510..747c1a64fbc 100644 --- a/settings.gradle +++ b/settings.gradle @@ -877,5 +877,6 @@ if (buildProperties.isKotlinNativeEnabled) { include ':kotlin-native:platformLibs' include ':kotlin-native:libclangext' include ':kotlin-native:backend.native:tests' - include ":kotlin-native:prepare:kotlin-native-compiler-embeddable" + include ':kotlin-native:prepare:kotlin-native-compiler-embeddable' + include ':native:kotlin-test-native-xctest' }