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' }