[ObjCExport] Add ObjCExport K1/K2 integration test

Generate K1 and K2 headers, compiler headers with Indexer
and compare indexer result.

^KT-76637 Fixed
diff --git a/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/Indexer.kt b/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/Indexer.kt
index 725feb4..3b783ec 100644
--- a/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/Indexer.kt
+++ b/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/Indexer.kt
@@ -67,7 +67,8 @@
         name: String,
         override val location: Location,
         override val isForwardDeclaration: Boolean,
-        override val binaryName: String?
+        override val binaryName: String?,
+        override val typeParameters: List<String> = emptyList<String>()
 ) : ObjCClass(name), ObjCContainerImpl {
     override val protocols = mutableListOf<ObjCProtocol>()
     override val methods = mutableListOf<ObjCMethod>()
@@ -412,6 +413,7 @@
         assert(cursor.kind == CXCursorKind.CXCursor_ObjCInterfaceDecl) { cursor.kind }
 
         val name = clang_getCursorDisplayName(cursor).convertAndDispose()
+        val parameters = mutableListOf<String>()
 
         if (isObjCInterfaceDeclForward(cursor)) {
             return objCClassRegistry.getOrPut(cursor) {
@@ -419,11 +421,24 @@
             }
         }
 
+        visitChildren(cursor) { child, _ ->
+            if (child.kind == CXCursorKind.CXCursor_TemplateTypeParameter) {
+                parameters += getCursorSpelling(child)
+            }
+            CXChildVisitResult.CXChildVisit_Continue
+        }
+
         return objCClassRegistry.getOrPut(cursor, {
-            ObjCClassImpl(name, getLocation(cursor), isForwardDeclaration = false,
-                    binaryName = getObjCBinaryName(cursor).takeIf { it != name })
+            ObjCClassImpl(
+                    name = name,
+                    location = getLocation(cursor),
+                    isForwardDeclaration = false,
+                    binaryName = getObjCBinaryName(cursor).takeIf { it != name },
+                    typeParameters = parameters
+            )
         }) { objcClass ->
             addChildrenToObjCContainer(cursor, objcClass)
+            objcClass.swiftName = readSwiftName(cursor)
             if (name in this.library.objCClassesIncludingCategories) {
                 // We don't include methods from categories to class during indexing
                 // because indexing does not care about how class is represented in Kotlin.
@@ -488,9 +503,11 @@
             ObjCProtocolImpl(name, getLocation(cursor), isForwardDeclaration = false)
         }) {
             addChildrenToObjCContainer(cursor, it)
+            it.swiftName = readSwiftName(cursor)
         }
     }
 
+
     private fun getObjCBinaryName(cursor: CValue<CXCursor>): String {
         val prefix = "_OBJC_CLASS_\$_"
         val symbolName = clang_Cursor_getObjCManglings(cursor)!!.convertAndDispose()
@@ -1005,6 +1022,7 @@
 
                     if (getter != null) {
                         val property = ObjCProperty(entityName!!, getter, setter)
+                        property.swiftName = readSwiftName(cursor)
                         val objCContainer: ObjCContainerImpl? = when (container.kind) {
                             CXCursorKind.CXCursor_ObjCCategoryDecl -> getObjCCategoryAt(container)
                             CXCursorKind.CXCursor_ObjCInterfaceDecl -> getObjCClassAt(container)
@@ -1098,16 +1116,18 @@
         }
 
         return ObjCMethod(
-            selector, encoding, parameters, returnType,
-            isVariadic = clang_Cursor_isVariadic(cursor) != 0,
-            isClass = isClass,
-            nsConsumesSelf = clang_Cursor_isObjCConsumingSelfMethod(cursor) != 0,
-            nsReturnsRetained = clang_Cursor_isObjCReturningRetainedMethod(cursor) != 0,
-            isOptional = (clang_Cursor_isObjCOptional(cursor) != 0),
-            isInit = (clang_Cursor_isObjCInitMethod(cursor) != 0),
-            isExplicitlyDesignatedInitializer = hasAttribute(cursor, OBJC_DESIGNATED_INITIALIZER),
-            isDirect = hasAttribute(cursor, OBJC_DIRECT),
-        )
+                selector, encoding, parameters, returnType,
+                isVariadic = clang_Cursor_isVariadic(cursor) != 0,
+                isClass = isClass,
+                nsConsumesSelf = clang_Cursor_isObjCConsumingSelfMethod(cursor) != 0,
+                nsReturnsRetained = clang_Cursor_isObjCReturningRetainedMethod(cursor) != 0,
+                isOptional = (clang_Cursor_isObjCOptional(cursor) != 0),
+                isInit = (clang_Cursor_isObjCInitMethod(cursor) != 0),
+                isExplicitlyDesignatedInitializer = hasAttribute(cursor, OBJC_DESIGNATED_INITIALIZER),
+                isDirect = hasAttribute(cursor, OBJC_DIRECT),
+        ).apply {
+            swiftName = readSwiftName(cursor)
+        }
     }
 
     // TODO: unavailable declarations should be imported as deprecated.
@@ -1260,4 +1280,4 @@
             clang_disposeTranslationUnit(translationUnit)
         }
     }
-}
+}
\ No newline at end of file
diff --git a/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt b/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt
index 39c4097..ba605fd 100644
--- a/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt
+++ b/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/NativeIndex.kt
@@ -255,14 +255,17 @@
 
 sealed class ObjCClassOrProtocol(val name: String) : ObjCContainer(), TypeDeclaration {
     abstract val isForwardDeclaration: Boolean
+    var swiftName: String? = null
 }
 
 data class ObjCMethod(
-        val selector: String, val encoding: String, val parameters: List<Parameter>, private val returnType: Type,
+        val selector: String, val encoding: String, val parameters: List<Parameter>, val returnType: Type,
         val isVariadic: Boolean, val isClass: Boolean, val nsConsumesSelf: Boolean, val nsReturnsRetained: Boolean,
         val isOptional: Boolean, val isInit: Boolean, val isExplicitlyDesignatedInitializer: Boolean, val isDirect: Boolean
 ) {
 
+    var swiftName: String? = null
+
     fun containsInstancetype(): Boolean = returnType.containsInstancetype() // Clang doesn't allow parameter types to use instancetype.
 
     fun getReturnType(container: ObjCClassOrProtocol): Type = if (returnType.containsInstancetype()) {
@@ -300,6 +303,7 @@
 
 data class ObjCProperty(val name: String, val getter: ObjCMethod, val setter: ObjCMethod?) {
     fun getType(container: ObjCClassOrProtocol): Type = getter.getReturnType(container)
+    var swiftName: String? = null
 }
 
 abstract class ObjCClass(name: String) : ObjCClassOrProtocol(name) {
@@ -309,6 +313,8 @@
      * Categories whose methods and properties should be generated as members of Kotlin class.
      */
     abstract val includedCategories: List<ObjCCategory>
+
+    open val typeParameters: List<String> get() = emptyList()
 }
 abstract class ObjCProtocol(name: String) : ObjCClassOrProtocol(name)
 
@@ -360,7 +366,7 @@
 
 object CharType : PrimitiveType
 
-open class BoolType: PrimitiveType
+open class BoolType : PrimitiveType
 
 object CBoolType : BoolType()
 
diff --git a/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/readSwiftName.kt b/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/readSwiftName.kt
new file mode 100644
index 0000000..2e415c9
--- /dev/null
+++ b/kotlin-native/Interop/Indexer/src/main/kotlin/org/jetbrains/kotlin/native/interop/indexer/readSwiftName.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2010-2025 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.
+ */
+
+package org.jetbrains.kotlin.native.interop.indexer
+
+import clang.*
+import kotlinx.cinterop.*
+
+
+internal fun readSwiftName(cursor: CValue<CXCursor>): String? {
+    var result: String? = null
+    visitChildren(cursor) { child, _ ->
+        val toKString = clang_Cursor_getAttributeSpelling(child)?.toKString()
+        if (clang_isAttribute(child.kind) != 0 && toKString == "swift_name") {
+            val tu = clang_Cursor_getTranslationUnit(child)!!
+            val extent = clang_getCursorExtent(child)
+            val rangeStart = clang_getRangeStart(extent)
+            val rangeEnd = clang_getRangeEnd(extent)
+
+            val fullText = getText(tu, rangeStart, rangeEnd)
+            if (fullText != null) {
+                result = fullText.substringAfter("\"").substringBefore("\"")
+            }
+        }
+        CXChildVisitResult.CXChildVisit_Continue
+    }
+    return result
+}
+
+private fun getText(tu: CXTranslationUnit, start: CValue<CXSourceLocation>, end: CValue<CXSourceLocation>): String? = memScoped {
+    val tokensVar = alloc<CPointerVar<CXToken>>()
+    val numTokensVar = alloc<IntVar>()
+    clang_tokenize(tu, clang_getRange(start, end), tokensVar.ptr, numTokensVar.ptr)
+    val numTokens = numTokensVar.value
+    val tokens = tokensVar.value ?: return null
+    try {
+        (0 until numTokens).joinToString("") { i ->
+            clang_getTokenSpelling(tu, tokens[i].readValue()).convertAndDispose()
+        }
+    } finally {
+        clang_disposeTokens(tu, tokens, numTokens)
+    }
+}
\ No newline at end of file
diff --git a/native/objcexport-header-generator/ReadMe.md b/native/objcexport-header-generator/ReadMe.md
index abee189..2910595 100644
--- a/native/objcexport-header-generator/ReadMe.md
+++ b/native/objcexport-header-generator/ReadMe.md
@@ -60,6 +60,34 @@
 
 Note: Since the Analysis Api implementation is WIP yet, this test can be used for debugging, but is not fully implemented yet.
 
+### How tests work on TC
+
+On TC all tests are called by `./gradlew check`. ObjCExport module has 2 groups of tests:
+- K1
+- K2
+  They use different classpaths to include different header generators:
+```kotlin
+objCExportHeaderGeneratorTest("testK1", testDisplayNameTag = "K1") {
+    classpath += k1TestRuntimeClasspath
+    exclude("**/ObjCExportIntegrationTest.class")
+}
+
+objCExportHeaderGeneratorTest("testAnalysisApi", testDisplayNameTag = "AA") {
+    classpath += analysisApiRuntimeClasspath
+    exclude("**/ObjCExportIntegrationTest.class")
+}
+```
+Also we configure order of execution:
+```kotlin
+objCExportHeaderGeneratorTest("testIntegration") {
+    mustRunAfter("testK1", "testAnalysisApi")
+}
+```
+So, when TC calls `./gradlew check` this happens:
+1. `GenerateObjCExportIntegrationTestData` called with K1 classpath and generated K1 header is stored `build` directory
+2. `GenerateObjCExportIntegrationTestData` called with K2 classpath and generated K2 header is stored `build` directory
+3.  `testIntegration` calls `ObjCExportIntegrationTest` which builds K1 and K2 indexes and compares them.
+
 ### CI setup and 'TodoAnalysisApi'
 As explained previously, tests in :native:objcexport-header-generator will be able to run against K1 and the AA implementation. 
 The CI will now run both cases. However, some tests are not yet expected to pass for the newer AA based implementation. 
@@ -84,4 +112,50 @@
 ```text
 ./gradlew :native:objcexport-header-generator:check -Pkif.local
                                                   //  ^
-```
\ No newline at end of file
+```
+
+## Integration tests
+
+Currently K1 and K2 versions of ObjCExport are used together in IDE, so it's important to keep eye on structural equality of generated
+headers by both versions. Here is an example of how headers might be valid, but cause issues in IDE:
+
+```kotlin
+interface ValueStorage {
+    fun storeValue(value: Boolean)
+    fun storeValue(value: String)
+}
+```
+
+```c
+@protocol ValueStorage
+- (void)storeValue:(BOOL)value __attribute__((swift_name("storeValue(value:)")));
+- (void)storeValue_:(NSString)value __attribute__((swift_name("storeValue(value_:)")));
+@end
+```
+
+```c
+@protocol ValueStorage
+- (void)storeValue:(BOOL)value __attribute__((swift_name("storeValue(value:)")));
+- (void)storeValue__:(NSString)value __attribute__((swift_name("storeValue(value__:)")));
+@end
+```
+
+Both headers are valid, but because of mangling bug `swift_name` attributes are different. So user can pass parameter
+with name `value_` in Swift and code is going to be green, but at compile time there will be error about absence of parameter `value_`.
+
+To verify structural differences:
+
+1. We generate headers
+   in [GenerateObjCExportIntegrationTestData](test/org/jetbrains/kotlin/backend/konan/tests/integration/GenerateObjCExportIntegrationTestData.kt)
+2. Then compile both headers with `Indexer` and compare indexer result
+   in [ObjCExportIntegrationTest](test/org/jetbrains/kotlin/backend/konan/tests/integration/ObjCExportIntegrationTest.kt)
+
+### To run/debug integration test
+
+1. `./gradlew :native:objcexport-header-generator:check --continue` to generate test data (can also be done from IDE by calling K1 and AA
+   test groups
+   on [GenerateObjCExportIntegrationTestData](test/org/jetbrains/kotlin/backend/konan/tests/integration/GenerateObjCExportIntegrationTestData.kt))
+2. Run test [ObjCExportIntegrationTest](test/org/jetbrains/kotlin/backend/konan/tests/integration/ObjCExportIntegrationTest.kt) in IDE in
+   debug mode
+3. Instance of [IntegrationTestReport](test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/IntegrationTestReport.kt) is going to
+   be created and can be inspected in debugger. 
\ No newline at end of file
diff --git a/native/objcexport-header-generator/build.gradle.kts b/native/objcexport-header-generator/build.gradle.kts
index e209523..0161ed9 100644
--- a/native/objcexport-header-generator/build.gradle.kts
+++ b/native/objcexport-header-generator/build.gradle.kts
@@ -1,5 +1,7 @@
 @file:Suppress("HasPlatformType")
 
+import org.gradle.api.tasks.PathSensitivity
+
 plugins {
     kotlin("jvm")
 }
@@ -16,6 +18,14 @@
     api(project(":native:base"))
 
     testImplementation(project(":native:external-projects-test-utils"))
+
+    if (kotlinBuildProperties.isKotlinNativeEnabled) {
+        testImplementation(project(":kotlin-native:Interop:Indexer"))
+        testImplementation(project(":native:kotlin-native-utils"))
+        testImplementation(project(":kotlin-native:Interop:StubGenerator"))
+        testImplementation(testFixtures(project(":native:native.tests")))
+    }
+
     testApi(libs.junit.jupiter.api)
     testApi(libs.junit.jupiter.engine)
     testApi(libs.junit.jupiter.params)
@@ -49,15 +59,40 @@
 
 objCExportHeaderGeneratorTest("testK1", testDisplayNameTag = "K1") {
     classpath += k1TestRuntimeClasspath
+    exclude("**/ObjCExportIntegrationTest.class")
 }
 
 objCExportHeaderGeneratorTest("testAnalysisApi", testDisplayNameTag = "AA") {
     classpath += analysisApiRuntimeClasspath
+    exclude("**/ObjCExportIntegrationTest.class")
 }
 
 tasks.check.configure {
     dependsOn("testK1")
     dependsOn("testAnalysisApi")
+    dependsOn("testIntegration")
     dependsOn(":native:objcexport-header-generator-k1:check")
     dependsOn(":native:objcexport-header-generator-analysis-api:check")
 }
+
+tasks.withType<Test>().configureEach {
+    systemProperty(
+        integrationTestOutputsDir,
+        layout.buildDirectory.dir(integrationTestOutputsDir).get().asFile.absolutePath
+    )
+}
+
+objCExportHeaderGeneratorTest("testIntegration", testDisplayNameTag = "testIntegration") {
+    filter {
+        includeTestsMatching("org.jetbrains.kotlin.backend.konan.tests.integration.ObjCExportIntegrationTest")
+    }
+    dependsOn("testK1", "testAnalysisApi")
+
+    inputs.dir(
+        layout.buildDirectory.dir(integrationTestOutputsDir)
+    ).withPathSensitivity(
+        PathSensitivity.RELATIVE
+    )
+}
+
+val integrationTestOutputsDir = "integration-test-outputs"
\ No newline at end of file
diff --git a/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/ObjCExportDependenciesHeaderGeneratorTest.kt b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/ObjCExportDependenciesHeaderGeneratorTest.kt
index 99a2c91..49916a6 100644
--- a/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/ObjCExportDependenciesHeaderGeneratorTest.kt
+++ b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/ObjCExportDependenciesHeaderGeneratorTest.kt
@@ -5,7 +5,9 @@
 
 package org.jetbrains.kotlin.backend.konan.tests
 
-import org.jetbrains.kotlin.backend.konan.testUtils.*
+import org.jetbrains.kotlin.backend.konan.testUtils.HeaderGenerator
+import org.jetbrains.kotlin.backend.konan.testUtils.TodoAnalysisApi
+import org.jetbrains.kotlin.backend.konan.testUtils.dependenciesDir
 import org.jetbrains.kotlin.konan.test.*
 import org.jetbrains.kotlin.test.KotlinTestUtils
 import org.junit.jupiter.api.Test
diff --git a/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/GenerateObjCExportIntegrationTestData.kt b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/GenerateObjCExportIntegrationTestData.kt
new file mode 100644
index 0000000..957e605
--- /dev/null
+++ b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/GenerateObjCExportIntegrationTestData.kt
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2010-2025 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.
+ */
+
+package org.jetbrains.kotlin.backend.konan.tests.integration
+
+import org.jetbrains.kotlin.backend.konan.testUtils.HeaderGenerator
+import org.jetbrains.kotlin.backend.konan.tests.integration.utils.IntegrationTestFiles
+import org.jetbrains.kotlin.konan.test.*
+import org.junit.jupiter.api.Test
+import java.io.File
+import java.nio.file.Path
+import kotlin.io.path.name
+import kotlin.test.fail
+
+/**
+ * Generated test data is then verified by [ObjCExportIntegrationTest]
+ */
+class GenerateObjCExportIntegrationTestData(private val generator: HeaderGenerator) {
+
+    @Test
+    fun `generate headers`() {
+        generateAndStoreObjCHeader(testLibraryKotlinxDatetime.name, listOf(testLibraryKotlinxDatetime))
+        generateAndStoreObjCHeader(testLibraryKotlinxCoroutines.name, listOf(testLibraryKotlinxCoroutines))
+        generateAndStoreObjCHeader(testLibraryAtomicFu.name, listOf(testLibraryAtomicFu))
+        generateAndStoreObjCHeader(testLibraryKotlinxSerializationCore.name, listOf(testLibraryKotlinxSerializationCore))
+        generateAndStoreObjCHeader(testLibraryKotlinxSerializationJson.name, listOf(testLibraryKotlinxSerializationJson))
+
+        generateAndStoreObjCHeader(
+            "combined",
+            listOf(
+                testLibraryKotlinxDatetime,
+                testLibraryKotlinxCoroutines,
+                testLibraryAtomicFu,
+                testLibraryKotlinxSerializationCore,
+                testLibraryKotlinxSerializationJson
+            )
+        )
+    }
+
+    private fun generateAndStoreObjCHeader(name: String, libraries: List<Path>) {
+        val header = generateObjCHeader(libraries)
+        IntegrationTestFiles.storeHeader(name, header)
+    }
+
+    private fun generateObjCHeader(libraries: List<Path>): String {
+        return generateHeader(
+            root = IntegrationTestFiles.integrationDir,
+            configuration = HeaderGenerator.Configuration(
+                dependencies = libraries,
+                exportedDependencies = libraries.toSet(),
+                frameworkName = "",
+                withObjCBaseDeclarationStubs = false
+            )
+        )
+    }
+
+    private fun generateHeader(root: File, configuration: HeaderGenerator.Configuration = HeaderGenerator.Configuration()): String {
+        if (!root.isDirectory) fail("Expected ${root.absolutePath} to be directory")
+        return generator.generateHeaders(root, configuration).toString()
+    }
+}
\ No newline at end of file
diff --git a/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/ObjCExportIntegrationTest.kt b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/ObjCExportIntegrationTest.kt
new file mode 100644
index 0000000..156e539
--- /dev/null
+++ b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/ObjCExportIntegrationTest.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright 2010-2025 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.
+ */
+
+package org.jetbrains.kotlin.backend.konan.tests.integration
+
+import org.jetbrains.kotlin.backend.konan.tests.integration.utils.*
+import org.jetbrains.kotlin.backend.konan.tests.integration.utils.IntegrationTestReport.Issue
+import org.jetbrains.kotlin.konan.target.HostManager
+import org.jetbrains.kotlin.konan.test.blackbox.support.copyNativeHomeProperty
+import org.jetbrains.kotlin.native.interop.gen.jvm.KotlinPlatform
+import org.jetbrains.kotlin.native.interop.indexer.IndexerResult
+import org.jetbrains.kotlin.native.interop.tool.ToolConfig
+import org.junit.jupiter.api.AfterEach
+import org.junit.jupiter.api.Assumptions.assumeTrue
+import org.junit.jupiter.api.BeforeEach
+import org.junit.jupiter.api.Test
+import java.io.File
+import kotlin.test.assertTrue
+
+/**
+ * Test data is generated by [GenerateObjCExportIntegrationTestData]
+ */
+class ObjCExportIntegrationTest {
+
+    init {
+        if (HostManager.host.family.isAppleFamily) {
+            copyNativeHomeProperty()
+            ToolConfig(
+                userProvidedTargetName = HostManager.hostName,
+                flavor = KotlinPlatform.NATIVE,
+                propertyOverrides = emptyMap(),
+                konanDataDir = null
+            ).loadLibclang()
+        }
+    }
+
+    private var files = IntegrationTempFiles(integrationModuleName)
+
+    /**
+     * Currently failing is disabled, since we have unresolved K1/K2 issues
+     */
+    private val failOnFoundIssues = false
+
+    @Test
+    fun integration() {
+        IntegrationTestFiles.getHeaders { name, k1, k2 ->
+            val report = compileIndexAndBuildReport(name, k1, k2)
+
+            if (failOnFoundIssues && report.hasIssues) {
+                error("Failed due to ${report.issues.size} issues found in headers. See the report below:\n\n$report")
+            }
+        }
+    }
+
+    @AfterEach
+    fun dispose() {
+        assumeTrue(HostManager.host.family.isAppleFamily)
+        disposeIndexerUtils()
+    }
+
+    @BeforeEach
+    fun before() {
+        assumeTrue(HostManager.host.family.isAppleFamily)
+        initIndexerUtils()
+        files = IntegrationTempFiles(integrationModuleName)
+        assertTrue(File(appleSdkPath).exists(), "Apple SDK not found at `${appleSdkPath}`")
+    }
+
+    private fun compileIndexAndBuildReport(name: String, k1Header: String, k2Header: String): IntegrationTestReport {
+
+        val baseHeader = files.file("Base.h", baseObjCTypes.trimIndent())
+
+        val k1Index = try {
+            compileAndIndex(k1Header, baseHeader)
+        } catch (e: Throwable) {
+            return IntegrationTestReport(name, listOf(Issue.FailedK1Compilation(e.message, k1Header, e)))
+        }
+        val k2Index = try {
+            compileAndIndex(k2Header, baseHeader)
+        } catch (e: Throwable) {
+            return IntegrationTestReport(name, listOf(Issue.FailedK2CompilationK2(e.message, k2Header, e)))
+        }
+
+        return IntegrationTestReport(
+            name, compareProtocolsOrClasses(k1Index, k2Index)
+        )
+    }
+
+    private fun compileAndIndex(header: String, baseHeader: File): IndexerResult {
+        val headerFile = files.file("Foo.h", header)
+        return org.jetbrains.kotlin.backend.konan.tests.integration.utils.compileAndIndex(
+            listOf(baseHeader, headerFile), files, integrationModuleName, "-isysroot", appleSdkPath, "-F", appleFrameworkPath
+        )
+    }
+}
\ No newline at end of file
diff --git a/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/IntegrationTestFiles.kt b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/IntegrationTestFiles.kt
new file mode 100644
index 0000000..de47b31
--- /dev/null
+++ b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/IntegrationTestFiles.kt
@@ -0,0 +1,62 @@
+/*
+ * Copyright 2010-2025 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.
+ */
+
+package org.jetbrains.kotlin.backend.konan.tests.integration.utils
+
+import org.jetbrains.kotlin.backend.konan.tests.integration.GenerateObjCExportIntegrationTestData
+import java.io.File
+
+internal object IntegrationTestFiles {
+
+    val integrationDir = File(
+        System.getProperty(outputsSystemProperty)?.let(::File) ?: error("Missing `$outputsSystemProperty` system property"),
+        "integrationTestFiles"
+    ).apply { mkdirs() }
+
+    fun getHeaders(headersHandler: (name: String, k1Header: String, k2Header: String) -> Unit) {
+        val generatorTestName = GenerateObjCExportIntegrationTestData::class.simpleName
+        if (integrationDir.listFiles().isEmpty()) {
+            error("No integration test files found in ${integrationDir.absolutePath}. Run `$generatorTestName`")
+        }
+        integrationDir.listFiles()?.forEach { file ->
+            val libName = file.name
+            headersHandler(
+                libName,
+                File(integrationDir, libName).listFiles()?.firstOrNull { h -> h.name == "k1.h" }?.readText()
+                    ?: error("No K1 header file for $libName. Run $generatorTestName"),
+                File(integrationDir, libName).listFiles()?.firstOrNull { it.name == "k2.h" }?.readText()
+                    ?: error("No K2 header file for $libName. Run $generatorTestName"),
+            )
+        }
+    }
+
+    fun storeHeader(name: String, content: String) {
+        val testTag = System.getProperty("testDisplayName.tag")
+        val isK1 = testTag == "K1"
+        val isK2 = testTag == "AA"
+        val fileName = if (isK1) "k1.h" else if (isK2) "k2.h" else error("Unknown test tag: `$testTag`")
+
+        File(File(integrationDir, name).apply { mkdirs() }, fileName)
+            .writeText(content)
+    }
+}
+
+internal class IntegrationTempFiles(name: String) {
+    private val tempRootDir = System.getProperty(outputsSystemProperty) ?: System.getProperty("java.io.tmpdir") ?: "."
+
+    val directory: File = File(tempRootDir, name).canonicalFile.also {
+        it.mkdirs()
+    }
+
+    fun file(relativePath: String, contents: String): File = File(directory, relativePath).canonicalFile.apply {
+        parentFile.mkdirs()
+        writeText(contents)
+    }
+}
+
+/**
+ * Reference to layout.buildDirectory
+ */
+private const val outputsSystemProperty = "integration-test-outputs"
\ No newline at end of file
diff --git a/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/IntegrationTestReport.kt b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/IntegrationTestReport.kt
new file mode 100644
index 0000000..bead76e
--- /dev/null
+++ b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/IntegrationTestReport.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright 2010-2025 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.
+ */
+
+package org.jetbrains.kotlin.backend.konan.tests.integration.utils
+
+import org.jetbrains.kotlin.native.interop.indexer.IndexerResult
+import org.jetbrains.kotlin.native.interop.indexer.ObjCClassOrProtocol
+
+internal data class IntegrationTestReport(
+    val name: String,
+    val issues: List<Issue>,
+) {
+    sealed class Issue {
+
+        data class DefinedInK2ButNotInK1(
+            val k2: String,
+            val k1Source: ObjCClassOrProtocol,
+        ) : Issue()
+
+        data class DefinedInK1ButNotInK2(
+            val k1: String,
+            val k1Source: ObjCClassOrProtocol,
+        ) : Issue()
+
+        data class ClassOrInterfaceName(
+            val k1: String,
+            val k2: String,
+            val k1Source: ObjCClassOrProtocol,
+            val k2Source: ObjCClassOrProtocol,
+        ) : Issue()
+
+        data class ClassOrInterfaceSwiftName(
+            val k1: String?, val k2: String?,
+            val k1Source: ObjCClassOrProtocol,
+            val k2Source: ObjCClassOrProtocol,
+        ) : Issue()
+
+        data class MethodsCount(
+            val k1: Int, val k2: Int,
+            val k1Source: ObjCClassOrProtocol,
+            val k2Source: ObjCClassOrProtocol,
+        ) : Issue()
+
+        data class PropertiesCount(
+            val k1: Int, val k2: Int,
+            val k1Source: ObjCClassOrProtocol,
+            val k2Source: ObjCClassOrProtocol,
+        ) : Issue()
+
+        data class MethodSelector(
+            val k1: String,
+            val k2: String,
+            val k1Source: ObjCClassOrProtocol,
+            val k2Source: ObjCClassOrProtocol,
+        ) : Issue()
+
+        data class MethodSwiftName(
+            val k1: String?, val k2: String?,
+            val k1Source: ObjCClassOrProtocol,
+            val k2Source: ObjCClassOrProtocol,
+        ) : Issue()
+
+        data class FailedK1Compilation(
+            val message: String?,
+            val header: String,
+            val error: Throwable,
+        ) : Issue()
+
+        data class FailedK2CompilationK2(
+            val message: String?,
+            val header: String,
+            val error: Throwable,
+        ) : Issue()
+    }
+
+    val hasIssues: Boolean
+        get() {
+            return issues.isNotEmpty()
+        }
+}
+
+internal fun compareProtocolsOrClasses(
+    ik1: IndexerResult,
+    ik2: IndexerResult,
+): List<IntegrationTestReport.Issue> {
+    val k1Classes = ik1.index.objCClasses.associateBy { it.name }
+    val k2Classes = ik2.index.objCClasses.associateBy { it.name }
+    val k1Protocols = ik1.index.objCProtocols.associateBy { it.name }
+    val k2Protocols = ik2.index.objCProtocols.associateBy { it.name }
+
+    return compareProtocolsOrClasses(k1Classes, k2Classes) + compareProtocolsOrClasses(k1Protocols, k2Protocols)
+}
+
+private fun compareMethods(k1: ObjCClassOrProtocol, k2: ObjCClassOrProtocol): List<IntegrationTestReport.Issue> {
+    val result = mutableListOf<IntegrationTestReport.Issue>()
+
+    k1.methods.forEachIndexed { i1, m1 ->
+        val m2 = k2.methods.getOrNull(i1)
+
+        if (m1.selector != m2?.selector) {
+            result.add(IntegrationTestReport.Issue.MethodSelector(m1.selector, m2?.selector ?: "", k1, k2))
+        }
+
+        if (m1.swiftName != m2?.swiftName) {
+            result.add(IntegrationTestReport.Issue.MethodSwiftName(m1.swiftName, m2?.swiftName ?: "", k1, k2))
+        }
+    }
+
+    return result
+}
+
+private fun compareProperties(k1: ObjCClassOrProtocol, k2: ObjCClassOrProtocol): List<IntegrationTestReport.Issue> {
+    val result = mutableListOf<IntegrationTestReport.Issue>()
+
+    k1.properties.forEachIndexed { i1, p1 ->
+        val p2 = k2.properties.getOrNull(i1)
+
+        if (p1.name != p2?.name) {
+            result.add(IntegrationTestReport.Issue.MethodSelector(p1.name, p2?.name ?: "", k1, k2))
+        }
+
+        if (p1.swiftName != p2?.swiftName) {
+            result.add(IntegrationTestReport.Issue.MethodSwiftName(p1.swiftName, p2?.swiftName ?: "", k1, k2))
+        }
+    }
+
+    return result
+}
+
+private fun compareProtocolsOrClasses(
+    k1: Map<String, ObjCClassOrProtocol>,
+    k2: Map<String, ObjCClassOrProtocol>,
+): List<IntegrationTestReport.Issue> {
+
+    val result = mutableListOf<IntegrationTestReport.Issue>()
+
+    k1.forEach { (name, k1Container) ->
+
+        if (!k2.keys.contains(name)) {
+            result.add(IntegrationTestReport.Issue.DefinedInK1ButNotInK2(name, k1Container))
+        } else {
+            val k1Container = k1[name] ?: error("K1 container is null for $name")
+            val k2Container = k2[name] ?: error("K2 container is null for $name")
+
+            if (k1Container.swiftName != k2Container.swiftName) {
+                result.add(
+                    IntegrationTestReport.Issue.ClassOrInterfaceSwiftName(
+                        k1Container.swiftName, k2Container.swiftName, k1Container, k2Container
+                    )
+                )
+            }
+
+            if (k1Container.name != k2Container.name) {
+                result.add(
+                    IntegrationTestReport.Issue.ClassOrInterfaceName(
+                        k1Container.name, k2Container.name, k1Container, k2Container
+                    )
+                )
+            }
+
+            if (k1Container.methods.size != k2Container.methods.size) {
+                result.add(
+                    IntegrationTestReport.Issue.MethodsCount(
+                        k1Container.methods.size, k2Container.methods.size, k1Container, k2Container
+                    )
+                )
+            } else {
+                result.addAll(compareMethods(k1Container, k2Container))
+            }
+
+            if (k1Container.properties.size != k2Container.properties.size) {
+                result.add(
+                    IntegrationTestReport.Issue.PropertiesCount(
+                        k1Container.properties.size, k2Container.properties.size, k1Container, k2Container
+                    )
+                )
+            } else {
+                result.addAll(compareProperties(k1Container, k2Container))
+            }
+
+        }
+    }
+
+    k2.forEach { (name, k2Container) ->
+        if (!k1.keys.contains(name)) {
+            result.add(IntegrationTestReport.Issue.DefinedInK2ButNotInK1(name, k2Container))
+        }
+    }
+
+    return result
+}
\ No newline at end of file
diff --git a/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/baseObjCTypes.kt b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/baseObjCTypes.kt
new file mode 100644
index 0000000..a961fab
--- /dev/null
+++ b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/baseObjCTypes.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright 2010-2025 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.
+ */
+
+package org.jetbrains.kotlin.backend.konan.tests.integration.utils
+
+internal const val baseObjCTypes = """
+    
+    #ifndef CINTEROP_SHIMS_H
+    #define CINTEROP_SHIMS_H
+    
+    #import <Foundation/NSArray.h>
+    #import <Foundation/NSDictionary.h>
+    #import <Foundation/NSError.h>
+    #import <Foundation/NSObject.h>
+    #import <Foundation/NSSet.h>
+    #import <Foundation/NSString.h>
+    #import <Foundation/NSValue.h>
+    
+    __attribute__((swift_name("KotlinBase")))
+    @interface Base : NSObject
+    - (instancetype)init __attribute__((unavailable));
+    + (instancetype)new __attribute__((unavailable));
+    + (void)initialize __attribute__((objc_requires_super));
+    @end
+    @interface Base (BaseCopying) <NSCopying>
+    @end
+    __attribute__((swift_name("KotlinMutableSet")))
+    @interface MutableSet<ObjectType> : NSMutableSet<ObjectType>
+    @end
+    __attribute__((swift_name("KotlinMutableDictionary")))
+    @interface MutableDictionary<KeyType, ObjectType> : NSMutableDictionary<KeyType, ObjectType>
+    @end
+    @interface NSError (NSErrorKotlinException)
+    @property (readonly) id _Nullable kotlinException;
+    @end
+    __attribute__((swift_name("KotlinNumber")))
+    @interface Number : NSNumber
+    - (instancetype)initWithChar:(char)value __attribute__((unavailable));
+    - (instancetype)initWithUnsignedChar:(unsigned char)value __attribute__((unavailable));
+    - (instancetype)initWithShort:(short)value __attribute__((unavailable));
+    - (instancetype)initWithUnsignedShort:(unsigned short)value __attribute__((unavailable));
+    - (instancetype)initWithInt:(int)value __attribute__((unavailable));
+    - (instancetype)initWithUnsignedInt:(unsigned int)value __attribute__((unavailable));
+    - (instancetype)initWithLong:(long)value __attribute__((unavailable));
+    - (instancetype)initWithUnsignedLong:(unsigned long)value __attribute__((unavailable));
+    - (instancetype)initWithLongLong:(long long)value __attribute__((unavailable));
+    - (instancetype)initWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable));
+    - (instancetype)initWithFloat:(float)value __attribute__((unavailable));
+    - (instancetype)initWithDouble:(double)value __attribute__((unavailable));
+    - (instancetype)initWithBool:(BOOL)value __attribute__((unavailable));
+    - (instancetype)initWithInteger:(NSInteger)value __attribute__((unavailable));
+    - (instancetype)initWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable));
+    + (instancetype)numberWithChar:(char)value __attribute__((unavailable));
+    + (instancetype)numberWithUnsignedChar:(unsigned char)value __attribute__((unavailable));
+    + (instancetype)numberWithShort:(short)value __attribute__((unavailable));
+    + (instancetype)numberWithUnsignedShort:(unsigned short)value __attribute__((unavailable));
+    + (instancetype)numberWithInt:(int)value __attribute__((unavailable));
+    + (instancetype)numberWithUnsignedInt:(unsigned int)value __attribute__((unavailable));
+    + (instancetype)numberWithLong:(long)value __attribute__((unavailable));
+    + (instancetype)numberWithUnsignedLong:(unsigned long)value __attribute__((unavailable));
+    + (instancetype)numberWithLongLong:(long long)value __attribute__((unavailable));
+    + (instancetype)numberWithUnsignedLongLong:(unsigned long long)value __attribute__((unavailable));
+    + (instancetype)numberWithFloat:(float)value __attribute__((unavailable));
+    + (instancetype)numberWithDouble:(double)value __attribute__((unavailable));
+    + (instancetype)numberWithBool:(BOOL)value __attribute__((unavailable));
+    + (instancetype)numberWithInteger:(NSInteger)value __attribute__((unavailable));
+    + (instancetype)numberWithUnsignedInteger:(NSUInteger)value __attribute__((unavailable));
+    @end
+    __attribute__((swift_name("KotlinUByte")))
+    @interface UByte : Number
+    - (instancetype)initWithUnsignedChar:(unsigned char)value;
+    + (instancetype)numberWithUnsignedChar:(unsigned char)value;
+    @end
+    __attribute__((swift_name("KotlinShort")))
+    @interface Short : Number
+    - (instancetype)initWithShort:(short)value;
+    + (instancetype)numberWithShort:(short)value;
+    @end
+    __attribute__((swift_name("KotlinUShort")))
+    @interface UShort : Number
+    - (instancetype)initWithUnsignedShort:(unsigned short)value;
+    + (instancetype)numberWithUnsignedShort:(unsigned short)value;
+    @end
+    __attribute__((swift_name("KotlinInt")))
+    @interface Int : Number
+    - (instancetype)initWithInt:(int)value;
+    + (instancetype)numberWithInt:(int)value;
+    @end
+    __attribute__((swift_name("KotlinUInt")))
+    @interface UInt : Number
+    - (instancetype)initWithUnsignedInt:(unsigned int)value;
+    + (instancetype)numberWithUnsignedInt:(unsigned int)value;
+    @end
+    __attribute__((swift_name("KotlinLong")))
+    @interface Long : Number
+    - (instancetype)initWithLongLong:(long long)value;
+    + (instancetype)numberWithLongLong:(long long)value;
+    @end
+    __attribute__((swift_name("KotlinULong")))
+    @interface ULong : Number
+    - (instancetype)initWithUnsignedLongLong:(unsigned long long)value;
+    + (instancetype)numberWithUnsignedLongLong:(unsigned long long)value;
+    @end
+    __attribute__((swift_name("KotlinFloat")))
+    @interface Float : Number
+    - (instancetype)initWithFloat:(float)value;
+    + (instancetype)numberWithFloat:(float)value;
+    @end
+    __attribute__((swift_name("KotlinDouble")))
+    @interface Double : Number
+    - (instancetype)initWithDouble:(double)value;
+    + (instancetype)numberWithDouble:(double)value;
+    @end
+
+    #endif // CINTEROP_SHIMS_H
+"""
\ No newline at end of file
diff --git a/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/compileAndIndex.kt b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/compileAndIndex.kt
new file mode 100644
index 0000000..a051061
--- /dev/null
+++ b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/compileAndIndex.kt
@@ -0,0 +1,69 @@
+/*
+ * Copyright 2010-2025 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.
+ */
+
+package org.jetbrains.kotlin.backend.konan.tests.integration.utils
+
+import org.jetbrains.kotlin.native.interop.indexer.*
+import java.io.File
+
+internal fun compileAndIndex(
+    headers: List<File>,
+    files: IntegrationTempFiles,
+    vararg args: String,
+): IndexerResult {
+
+    val headersNames = headers.map {
+        "header \"" + it.name + "\"\n"
+    }
+
+    files.file(
+        "module.modulemap", """
+            module Foo {
+              ${headersNames.joinToString(separator = "")}
+            }
+        """.trimIndent()
+    )
+
+    val includeInfos = headers.map {
+        IncludeInfo(it.absolutePath, integrationModuleName)
+    }
+
+    val compilation = compilation(
+        includeInfos,
+        "-I${files.directory}",
+        "-I${getClangResourceDir()}",
+        *args
+    )
+
+    val nativeLibrary = NativeLibrary(
+        includes = compilation.includes,
+        additionalPreambleLines = compilation.additionalPreambleLines,
+        compilerArgs = compilation.compilerArgs,
+        headerToIdMapper = HeaderToIdMapper(sysRoot = ""),
+        language = compilation.language,
+        excludeSystemLibs = false,
+        headerExclusionPolicy = HeaderExclusionPolicyImpl(),
+        headerFilter = NativeLibraryHeaderFilter.Predefined(
+            files.directory.listFiles()?.filter { it.extension == "h" }?.map { it.path }.orEmpty().toSet(), listOf("*")
+        ),
+        objCClassesIncludingCategories = emptySet(),
+        allowIncludingObjCCategoriesFromDefFile = false
+    )
+
+    return buildNativeIndex(nativeLibrary, verbose = true)
+}
+
+private class HeaderExclusionPolicyImpl : HeaderExclusionPolicy {
+    override fun excludeAll(headerId: HeaderId): Boolean = false
+}
+
+private fun compilation(includes: List<IncludeInfo>, vararg args: String) = CompilationImpl(
+    includes = includes,
+    additionalPreambleLines = emptyList(),
+    compilerArgs = listOf(*args),
+    language = Language.OBJECTIVE_C
+)
+
+internal const val integrationModuleName = "Foo"
\ No newline at end of file
diff --git a/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/indexerUtils.kt b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/indexerUtils.kt
new file mode 100644
index 0000000..dcb724a
--- /dev/null
+++ b/native/objcexport-header-generator/test/org/jetbrains/kotlin/backend/konan/tests/integration/utils/indexerUtils.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2010-2025 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.
+ */
+
+package org.jetbrains.kotlin.backend.konan.tests.integration.utils
+
+import kotlinx.cinterop.JvmCInteropCallbacks
+import org.jetbrains.kotlin.konan.exec.Command
+import org.jetbrains.kotlin.konan.target.Xcode
+import org.jetbrains.kotlin.utils.NativeMemoryAllocator
+
+private val xcode = Xcode.Companion.findCurrent()
+internal val appleSdkPath = xcode.macosxSdk
+internal val appleFrameworkPath = "$appleSdkPath/System/Library/Frameworks"
+
+internal fun initIndexerUtils() {
+    NativeMemoryAllocator.Companion.init()
+    JvmCInteropCallbacks.init()
+}
+
+internal fun disposeIndexerUtils() {
+    JvmCInteropCallbacks.dispose()
+    NativeMemoryAllocator.Companion.dispose()
+}
+
+internal fun getClangResourceDir(): String {
+    val clangPath = Command("/usr/bin/xcrun", "-f", "clang").getOutputLines().first()
+    val resourceDir = Command(clangPath, "--print-resource-dir").getOutputLines().first()
+    return "$resourceDir/include"
+}
\ No newline at end of file