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