Коммит cf06b4f3 создал по автору Ilya Pankratov's avatar Ilya Pankratov
Просмотр файлов

Set up tests for ksp

владелец aca994aa
[versions]
compiler-testing-ksp = "0.7.0"
kotlin = "2.1.10"
kotlinx-coroutines = "1.10.1"
ksp = "2.1.10-1.0.29"
......@@ -10,6 +11,13 @@ kotlin-poet-ksp = { module = "com.squareup:kotlinpoet-ksp", version.ref = "kotli
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
ksp-api = { module = "com.google.devtools.ksp:symbol-processing-api", version.ref = "ksp" }
# Testing libraries
compiler-test-ksp = { module = "dev.zacsweers.kctfork:ksp", version.ref = "compiler-testing-ksp" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-gradle-plugin = { module = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" }
kotlin-compiler-embeddable = { module = "org.jetbrains.kotlin:kotlin-compiler-embeddable", version.ref = "kotlin" }
[plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }
kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
......@@ -7,6 +7,13 @@ dependencies {
implementation(libs.ksp.api)
implementation(libs.kotlin.poet)
implementation(libs.kotlin.poet.ksp)
testImplementation(libs.kotlin.test)
testImplementation(libs.compiler.test.ksp)
testImplementation(gradleApi())
testImplementation(libs.kotlin.compiler.embeddable)
testImplementation(libs.kotlin.gradle.plugin)
testImplementation(project((":qtbindings-annotations")))
}
kotlin {
......@@ -32,3 +39,11 @@ publishing {
mavenLocal()
}
}
// TODO: find another way to add dependencies for tests. Now dependencies are published to the Maven Local
tasks.test {
val testCompilationDependencies = listOf("qtbindings-annotations", "qtbindings-core")
testCompilationDependencies.forEach {
dependsOn(project.rootProject.project(it).tasks.publishToMavenLocal)
}
}
/**
* SPDX-FileCopyrightText: Copyright 2025 Open Mobile Platform LLC <community@omp.ru>
* SPDX-License-Identifier: BSD-3-Clause
*/
package ru.aurora.kmp.qtbindings.ksp
import ru.aurora.kmp.qtbindings.ksp.internal.*
import com.tschuchort.compiletesting.*
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import org.jetbrains.kotlin.konan.target.KonanTarget
import org.junit.Before
import org.junit.Rule
import org.junit.rules.TemporaryFolder
import java.io.ByteArrayOutputStream
import java.io.File
import java.nio.file.Path
data class GeneratedFile(val path: Path, val content: String)
@OptIn(ExperimentalCompilerApi::class)
open class BaseCompilationTest {
@Rule
@JvmField
val temporaryFolder: TemporaryFolder = TemporaryFolder().apply { }
lateinit var konan: KotlinNativeCompiler
lateinit var qtCompiler: QtCompiler
companion object {
const val EMPTY_TEST_FILE = "int main() { return 0; }" // Check that compilation works
}
@Before
fun setUp() {
konan = KotlinNativeCompiler(temporaryFolder.root)
qtCompiler = QtCompiler(temporaryFolder.root)
}
fun run(
kotlinSources: List<SourceFile>, generatedFiles: List<GeneratedFile> = listOf()
): KotlinCompilation {
return KotlinCompilation().apply {
languageVersion = "2.1"
workingDir = temporaryFolder.root
messageOutputStream = ByteArrayOutputStream()
configureKsp(useKsp2 = true) {
symbolProcessorProviders += QtBindingsSymbolProcessingProvider()
}
sources = kotlinSources
inheritClassPath = true
kspWithCompilation = true
kspIncremental = true
kspProcessorOptions = mutableMapOf("qtBindings.libName" to "shared")
// Could not find a way for testing native compilation,
// so let's just compare expected and actual generated code
compile() //.assertSuccessfulCompilation()
for (file in generatedFiles) {
assertThatFileHasBeenGenerated(file.path, file.content)
}
}
}
fun run(file: SourceFile, vararg generatedFiles: GeneratedFile) =
run(listOf(file), generatedFiles.asList())
fun run(file: SourceFile, generatedFiles: List<GeneratedFile> = listOf()) =
run(listOf(file), generatedFiles)
fun creteFileAtWorkingDirectory(filename: String, content: String): File {
val file = temporaryFolder.root.resolve(filename)
file.bufferedWriter().use { it.write(content) }
return file
}
fun compileAndRunQtTestCase(compilation: KotlinCompilation, qtTestCase: String = EMPTY_TEST_FILE): ProcessResult {
val kotlinFiles = compilation.getKotlinSources()
val (headers, sources) = compilation.getCppFiles()
val konanResult =
konan.compileStaticLibrary(KonanTarget.LINUX_X64, KotlinNativeCompiler.DEFAULT_LIB_NAME, kotlinFiles)
konanResult.assertIsOk()
val testFile = creteFileAtWorkingDirectory("test.cpp", qtTestCase)
val qtCompilationResult =
qtCompiler.compileExecutable(headers, sources + testFile, konanResult.cLibrary, konanResult.cHeader)
qtCompilationResult.assertIsOk()
return NativeProcessRunner(qtCompilationResult.executable).run()
}
}
/**
* SPDX-FileCopyrightText: Copyright 2025 Open Mobile Platform LLC <community@omp.ru>
* SPDX-License-Identifier: BSD-3-Clause
*/
@file:OptIn(ExperimentalCompilerApi::class)
package ru.aurora.kmp.qtbindings.ksp.internal
import org.jetbrains.kotlin.compiler.plugin.ExperimentalCompilerApi
import com.tschuchort.compiletesting.KotlinCompilation
import com.tschuchort.compiletesting.kspSourcesDir
import java.io.File
import java.nio.file.Path
import kotlin.io.path.Path
import kotlin.io.path.extension
import kotlin.math.max
val KotlinCompilation.sourceDir: File get() = workingDir.resolve("sources")
val KotlinCompilation.kspKotlinSourcesDir: File get() = kspSourcesDir.resolve("kotlin")
val KotlinCompilation.kspResourcesDir: File get() = kspSourcesDir.resolve("resources")
fun KotlinCompilation.getKotlinSources(): List<File> {
val generatedKotlinSources = kspKotlinSourcesDir.walkTopDown().filter { it.name.endsWith(".kt") }
val kotlinSources = sourceDir.walkTopDown().filter { it.name.endsWith(".kt") }
return (kotlinSources + generatedKotlinSources).toList()
}
/**
* Returns <Headers, Sources>.
*/
fun KotlinCompilation.getCppFiles(): Pair<List<File>, List<File>> {
val generatedSources = kspResourcesDir.walkTopDown()
val headers = generatedSources.filter { it.name.endsWith(".hpp") || it.name.endsWith(".h") }
val sources = generatedSources.filter { it.name.endsWith(".cpp") || it.name.endsWith(".с") }
return Pair(headers.toList(), sources.toList())
}
fun Path.toKspLocation(): Path {
return when (this.extension) {
"kt" -> {
Path("kotlin", toString())
}
"java" -> {
Path("java", toString())
}
else -> { // For .cpp, .hpp files
Path("resources", toString())
}
}
}
fun KotlinCompilation.assertThatFileHasBeenGenerated(path: Path, content: String) {
val path = path.toKspLocation()
val file = kspSourcesDir.resolve(path.toString())
file.assertThatExists()
val actualLines = file.readLines()
val expectedLines = content.lines()
val lines = max(actualLines.size, expectedLines.size)
for (i in 0..lines - 1) {
val actualLine =
actualLines.elementAtOrNull(i) ?: assert(false) { "Generated file has more lines than expected" }
val expectedLine =
expectedLines.elementAtOrNull(i) ?: assert(false) { "Generated file has less lines than expected" }
assert(actualLine == expectedLine) {
"The contents of the file differ on line $i: $path"
}
}
}
fun KotlinCompilation.assertThatFileHasContent(path: Path, content: List<String>) {
val file = kspSourcesDir.resolve(path.toKspLocation().toString())
file.assertThatExists()
val fileLines = file.readLines()
content.forEach { content ->
val contentLines = content.lines()
val sublistsOfSpecifiedSize = fileLines.windowed(contentLines.size)
assert(sublistsOfSpecifiedSize.any { it == contentLines }) {
"File $path does not contain:\n$content"
}
}
}
fun KotlinCompilation.assertThatFileHasContent(path: Path, vararg content: String) =
assertThatFileHasContent(path, content.asList())
fun KotlinCompilation.assertThatKSPOutputHasContent(vararg lines: String) {
val outputLines = messageOutputStream.toString().lines()
lines.forEach { line ->
assert(outputLines.any { it.contains(line) }) {
"Compiler output does not contain: $line"
}
}
}
\ Нет новой строки в конце файла
/**
* SPDX-FileCopyrightText: Copyright 2025 Open Mobile Platform LLC <community@omp.ru>
* SPDX-License-Identifier: BSD-3-Clause
*/
package ru.aurora.kmp.qtbindings.ksp.internal
import org.gradle.api.Project
import org.gradle.api.attributes.Usage
import org.gradle.testfixtures.ProjectBuilder
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.utils.NativeCompilerDownloader
import org.jetbrains.kotlin.konan.target.KonanTarget
import java.io.File
class NativeCompilationResult(
val exitCode: Int,
val stdout: String,
val stderr: String,
val cLibrary: File,
val cHeader: File,
) {
val isOk = exitCode == 0
fun assertIsOk() = assert(isOk) { "Failed to compile Kotlin C Library ${cLibrary.absolutePath}: $stderr" }
}
class KotlinNativeCompiler(workingDir: File) {
val project: Project = ProjectBuilder.builder().withProjectDir(workingDir).build()
val compilerExecutable: String
companion object {
const val DEFAULT_LIB_NAME = "shared"
}
init {
project.repositories.apply {
mavenCentral() // For Kotlin/Native compiler
mavenLocal() // For qtbindings-annotations, qtbindings-core
}
// Download Kotlin/Native compiler
val downloader = NativeCompilerDownloader(project)
downloader.downloadIfNeeded()
compilerExecutable = downloader.compilerDirectory.resolve("bin/konanc").normalize().absolutePath
}
fun compileStaticLibrary(target: KonanTarget, libName: String, files: List<File>): NativeCompilationResult {
check(target is KonanTarget.LINUX_X64 || target is KonanTarget.LINUX_ARM64)
val klibs = mutableSetOf<String>()
val projectDir = project.projectDir
val sources = files.map { it.path }
val outputDir = projectDir.resolve("kotlin-native-build").also { it.mkdirs() }
val outputName = "lib$libName.a"
val library = outputDir.resolve(outputName)
val header = outputDir.resolve("lib${libName}_api.h")
project.findKLibArtifactsForDependency("ru.aurora.kmp", "qtbindings-core", "0.1.0", target).forEach {
klibs.add(it.absolutePath)
}
project.findKLibArtifactsForDependency("ru.aurora.kmp", "qtbindings-annotations", "0.1.0", target).forEach {
klibs.add(it.absolutePath)
}
val command = mutableListOf(
compilerExecutable,
"-g",
*sources.toTypedArray(),
*klibs.flatMap { listOf("-l", it) }.toTypedArray(),
"-output",
library.path,
"-produce",
"static",
"-target",
target.name,
)
val process = ProcessBuilder(command).directory(projectDir).redirectOutput(ProcessBuilder.Redirect.PIPE)
.redirectError(ProcessBuilder.Redirect.PIPE).start()
val exitCode = process.waitFor()
val output = process.inputStream.bufferedReader().readText()
val errorOutput = process.errorStream.bufferedReader().readText()
return NativeCompilationResult(exitCode, output, errorOutput, library, header)
}
}
fun Project.findKLibArtifactsForDependency(
group: String, name: String, version: String, target: KonanTarget
): Set<File> {
val dependency = project.dependencies.create(
mapOf(
"group" to group, "name" to name, "version" to version
)
)
val config = project.configurations.detachedConfiguration(dependency).apply {
attributes.attribute(Usage.USAGE_ATTRIBUTE, project.objects.named(Usage::class.java, KotlinUsages.KOTLIN_API))
attributes.attribute(KotlinPlatformType.attribute, KotlinPlatformType.native)
attributes.attribute(KotlinNativeTarget.konanTargetAttribute, target.name)
}
return config.resolve()
}
/**
* SPDX-FileCopyrightText: Copyright 2025 Open Mobile Platform LLC <community@omp.ru>
* SPDX-License-Identifier: BSD-3-Clause
*/
package ru.aurora.kmp.qtbindings.ksp.internal
import java.io.File
class NativeProcessRunner(val executable: File) {
fun run(env: Map<String, String> = emptyMap()): ProcessResult {
val process = ProcessBuilder(executable.absolutePath)
.directory(executable.parentFile)
.apply { environment().putAll(env) }
.start()
val exitCode = process.waitFor()
val stdout = process.inputStream.bufferedReader().readText()
val stderr = process.errorStream.bufferedReader().readText()
return ProcessResult(executable, exitCode, stdout, stderr)
}
}
/**
* SPDX-FileCopyrightText: Copyright 2025 Open Mobile Platform LLC <community@omp.ru>
* SPDX-License-Identifier: BSD-3-Clause
*/
package ru.aurora.kmp.qtbindings.ksp.internal
import java.io.File
import kotlin.collections.windowed
open class ProcessResult(
val executable: File,
val exitCode: Int,
val stdout: String,
val stderr: String,
) {
val isOk = exitCode == 0
open fun assertIsOk() = assert(isOk) { "Failed to run executable ${executable.absolutePath} (exitCode=$exitCode): $stderr" }
fun assertStdoutHas(vararg lines: String) = assertHas(stdout, *lines)
fun assertStderrHas(vararg lines: String) = assertHas(stderr, *lines)
fun assertHas(output: String, vararg lines: String) {
val outputLines = output.lines()
lines.forEach { it ->
val contentLines = it.lines()
val sublistsOfSpecifiedSize = outputLines.windowed(contentLines.size)
assert(sublistsOfSpecifiedSize.any { it == contentLines }) { "Output does not have: $it" }
}
}
}
/**
* SPDX-FileCopyrightText: Copyright 2025 Open Mobile Platform LLC <community@omp.ru>
* SPDX-License-Identifier: BSD-3-Clause
*/
package ru.aurora.kmp.qtbindings.ksp.internal
import java.io.File
class QtCompilationResult(
exitCode: Int,
stdout: String,
stderr: String,
executable: File,
) : ProcessResult(executable, exitCode, stdout, stderr) {
override fun assertIsOk() = assert(isOk) { "Failed to compile ${executable.absolutePath}: $stdout" }
}
class QtCompiler(val workingDir: File) {
companion object {
private const val TEST_EXECUTABLE_NAME = "qtbindings-test"
private const val KOTLIN_INCLUDE_DIRS_TEMPLATE = "\$KOTLIN_INCLUDE_DIRS"
private const val KOTLIN_LINK_ARGS_TEMPLATE = "\$KOTLIN_LINK_ARGS"
private const val QT_BINDINGS_INCLUDE_DIRS_TEMPLATE = "\$QT_BINDINGS_INCLUDE_DIRS"
private const val QT_BINDINGS_HEADERS_TEMPLATE = "\$QT_BINDINGS_HEADERS"
private const val QT_BINDINGS_SOURCES_TEMPLATE = "\$QT_BINDINGS_SOURCES"
}
private val mesonTemplate = """
project('$TEST_EXECUTABLE_NAME', ['cpp'], version: '0.0.1')
qt5 = import('qt5')
kotlin_library_dep = declare_dependency(
include_directories: [$KOTLIN_INCLUDE_DIRS_TEMPLATE],
link_args: [$KOTLIN_LINK_ARGS_TEMPLATE],
)
deps = [
dependency('qt5', modules: ['Core', 'Concurrent']),
kotlin_library_dep,
]
inc = include_directories(${QT_BINDINGS_INCLUDE_DIRS_TEMPLATE})
headers = [$QT_BINDINGS_HEADERS_TEMPLATE]
sources = [$QT_BINDINGS_SOURCES_TEMPLATE]
mocs = qt5.compile_moc(sources: sources, headers: headers)
executable(
'$TEST_EXECUTABLE_NAME',
[sources, mocs],
dependencies: deps,
include_directories: inc,
)
""".trimIndent()
fun compileExecutable(headers: List<File>, sources: List<File>, cLibrary: File, cHeader: File): QtCompilationResult {
val mesonFile = createMesonFile(headers, sources, cLibrary, cHeader)
val executableFile = File(workingDir, "build/$TEST_EXECUTABLE_NAME")
val setupMesonCommand = "meson setup build".split(' ')
val compileMesonCommand = "meson compile -C build".split(' ')
val setupMesonProcess = ProcessBuilder(setupMesonCommand).directory(mesonFile.parentFile).start()
val setupExitCode = setupMesonProcess.waitFor()
val setupStdout = setupMesonProcess.inputStream.bufferedReader().readText()
val setupStderr = setupMesonProcess.errorStream.bufferedReader().readText()
if (setupExitCode != 0) return QtCompilationResult(setupExitCode, setupStdout, setupStderr, executableFile)
val compileMesonProcess = ProcessBuilder(compileMesonCommand).directory(mesonFile.parentFile).start()
val compileExitCode = compileMesonProcess.waitFor()
val compileStdout = compileMesonProcess.inputStream.bufferedReader().readText()
val compileStderr = compileMesonProcess.errorStream.bufferedReader().readText()
return QtCompilationResult(compileExitCode, compileStdout, compileStderr, executableFile)
}
private fun getRelativePaths(files: List<File>): List<String> =
files.map { it.relativeTo(workingDir).path }.toSet().toList().distinct()
private fun getRelativeIncludeDirectories(headers: List<File>): List<String> =
getRelativePaths(headers.map { it.parentFile })
private fun getRelativeIncludeDirectories(vararg headers: File) = getRelativeIncludeDirectories(headers.toList())
private fun createMesonFile(headers: List<File>, sources: List<File>, cLibrary: File, cHeader: File): File {
val kotlinIncDirs = getRelativeIncludeDirectories(cHeader)
val kotlinLinkArgs = listOf("-L${cLibrary.parent}", "-l${cLibrary.name.removePrefix("lib").removeSuffix(".a")}")
val qtBindingsIncludeDirs = getRelativeIncludeDirectories(headers)
val qtBindingsHeaders = getRelativePaths(headers)
val qtBindingsSources = getRelativePaths(sources)
val mesonContent = mesonTemplate.replace(KOTLIN_INCLUDE_DIRS_TEMPLATE, kotlinIncDirs.joinToString { "'$it'" })
.replace(KOTLIN_LINK_ARGS_TEMPLATE, kotlinLinkArgs.joinToString { "'$it'" })
.replace(QT_BINDINGS_INCLUDE_DIRS_TEMPLATE, qtBindingsIncludeDirs.joinToString { "'$it'" })
.replace(QT_BINDINGS_HEADERS_TEMPLATE, qtBindingsHeaders.joinToString { "'$it'" })
.replace(QT_BINDINGS_SOURCES_TEMPLATE, qtBindingsSources.joinToString { "'$it'" })
val mesonFile = File(workingDir, "meson.build")
mesonFile.writeText(mesonContent)
return mesonFile
}
}
/**
* SPDX-FileCopyrightText: Copyright 2025 Open Mobile Platform LLC <community@omp.ru>
* SPDX-License-Identifier: BSD-3-Clause
*/
package ru.aurora.kmp.qtbindings.ksp.internal
import java.io.File
fun File.assertThatExists() {
assert(exists()) { "File does not exist: $path" }
}
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать