diff --git a/build.gradle.kts b/build.gradle.kts index ab72daf..434248a 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -19,7 +19,6 @@ import okhttp3.MultipartBody import okhttp3.Request import okhttp3.RequestBody.Companion.asRequestBody import okhttp3.internal.immutableListOf -import okhttp3.internal.toHexString import org.ajoberstar.grgit.Tag import org.objectweb.asm.ClassReader import org.objectweb.asm.ClassWriter @@ -31,10 +30,7 @@ import java.io.FileOutputStream import java.net.URI import java.nio.file.Files import java.time.ZonedDateTime -import kotlin.io.path.* -import kotlin.io.path.Path -import kotlin.io.path.deleteRecursively -import kotlin.io.path.exists +import kotlin.math.max plugins { id("java") @@ -543,20 +539,86 @@ python { pip("portablemc:${"portablemc_version"()}") } -data class SmokeTestConfig( - val modLoader: String, - val mcVersion: String, - val loaderVersion: String? = null, - val jvmVersion: Int? = null, - val extraArgs: List? = null, - val dependencies: List>? = null, -) { - val versionString: String get() = - if (loaderVersion != null) - "${modLoader}:${mcVersion}:${loaderVersion}" - else - "${modLoader}:${mcVersion}" +val smokeTest = tasks.register("smokeTest") { + group = "verification" + dependsOn(tasks.checkPython, tasks.pipInstall, compressJar) + doFirst { + SmokeTest( + logger, + "${project.rootDir}/.gradle/python/bin/portablemc", + compressJar.get().outputJar.asFile.get(), + "${project.rootDir}/.gradle/portablemc", + "${project.layout.buildDirectory.get()}/smoke_test", + max(2, Runtime.getRuntime().availableProcessors() / 5), + TimeUnit.SECONDS.toNanos(60), + listOf( + SmokeTest.Config("fabric", "snapshot", dependencies = listOf( + "fabric-api" to "https://github.com/FabricMC/fabric/releases/download/0.107.0%2B1.21.4/fabric-api-0.107.0+1.21.4.jar", + )), + SmokeTest.Config("fabric", "release", dependencies = listOf( + "fabric-api" to "https://github.com/FabricMC/fabric/releases/download/0.107.0%2B1.21.3/fabric-api-0.107.0+1.21.3.jar", + "modmenu" to "https://github.com/TerraformersMC/ModMenu/releases/download/v11.0.3/modmenu-11.0.3.jar", + )), + SmokeTest.Config("fabric", "1.21.1", dependencies = listOf( + "fabric-api" to "https://github.com/FabricMC/fabric/releases/download/0.107.0%2B1.21.1/fabric-api-0.107.0+1.21.1.jar", + "modmenu" to "https://github.com/TerraformersMC/ModMenu/releases/download/v11.0.3/modmenu-11.0.3.jar", + )), + SmokeTest.Config("fabric", "1.20.6", dependencies = listOf( + "fabric-api" to "https://github.com/FabricMC/fabric/releases/download/0.100.8%2B1.20.6/fabric-api-0.100.8+1.20.6.jar", + "modmenu" to "https://github.com/TerraformersMC/ModMenu/releases/download/v10.0.0/modmenu-10.0.0.jar", + )), + SmokeTest.Config("fabric", "1.20.1", dependencies = listOf( + "fabric-api" to "https://github.com/FabricMC/fabric/releases/download/0.92.2%2B1.20.1/fabric-api-0.92.2+1.20.1.jar", + "modmenu" to "https://github.com/TerraformersMC/ModMenu/releases/download/v7.2.2/modmenu-7.2.2.jar", + )), + SmokeTest.Config("fabric", "1.18.2", dependencies = listOf( + "fabric-api" to "https://github.com/FabricMC/fabric/releases/download/0.77.0%2B1.18.2/fabric-api-0.77.0+1.18.2.jar", + "modmenu" to "https://github.com/TerraformersMC/ModMenu/releases/download/v3.2.5/modmenu-3.2.5.jar", + ), extraArgs = listOf("--lwjgl=3.2.3")), + SmokeTest.Config("fabric", "1.16.5", dependencies = listOf( + "fabric-api" to "https://github.com/FabricMC/fabric/releases/download/0.42.0%2B1.16/fabric-api-0.42.0+1.16.jar", + "modmenu" to "https://github.com/TerraformersMC/ModMenu/releases/download/v1.16.23/modmenu-1.16.23.jar", + )), + SmokeTest.Config("fabric", "1.14.4", dependencies = listOf( + "fabric-api" to "https://github.com/FabricMC/fabric/releases/download/0.28.5%2B1.14/fabric-api-0.28.5+1.14.jar", + "modmenu" to "https://github.com/TerraformersMC/ModMenu/releases/download/v1.7.11/modmenu-1.7.11+build.121.jar", + )), + SmokeTest.Config("legacyfabric", "1.12.2", dependencies = listOf( + "legacy-fabric-api" to "https://github.com/Legacy-Fabric/fabric/releases/download/1.10.2/legacy-fabric-api-1.10.2.jar", + )), + SmokeTest.Config("legacyfabric", "1.8.9", dependencies = listOf( + "legacy-fabric-api" to "https://github.com/Legacy-Fabric/fabric/releases/download/1.10.2/legacy-fabric-api-1.10.2.jar", + )), + SmokeTest.Config("legacyfabric", "1.7.10", dependencies = listOf( + "legacy-fabric-api" to "https://github.com/Legacy-Fabric/fabric/releases/download/1.10.2/legacy-fabric-api-1.10.2.jar", + )), +// SmokeTest.Config("legacyfabric", "1.6.4", dependencies = listOf( +// "legacy-fabric-api" to "https://github.com/Legacy-Fabric/fabric/releases/download/1.10.2/legacy-fabric-api-1.10.2.jar", +// )), + SmokeTest.Config("babric", "b1.7.3", jvmVersion = 17, dependencies = listOf( + "station-api" to "https://cdn.modrinth.com/data/472oW63Q/versions/W3QVtn6S/StationAPI-2.0-alpha.2.4.jar", + ), extraArgs = listOf("--exclude-lib=asm-all")), + SmokeTest.Config("neoforge", "release"), + SmokeTest.Config("neoforge", "1.21.1"), + SmokeTest.Config("neoforge", "1.20.4"), + SmokeTest.Config("forge", "1.20.4"), + SmokeTest.Config("forge", "1.20.1"), + SmokeTest.Config("forge", "1.19.2"), + SmokeTest.Config("forge", "1.18.2", extraArgs = listOf("--lwjgl=3.2.3")), + SmokeTest.Config("forge", "1.16.5", extraArgs = listOf("--lwjgl=3.2.3")), + SmokeTest.Config("forge", "1.14.4", dependencies = listOf( + "mixinbootstrap" to "https://github.com/LXGaming/MixinBootstrap/releases/download/v1.1.0/_MixinBootstrap-1.1.0.jar" + ), extraArgs = listOf("--lwjgl=3.2.3")), + SmokeTest.Config("forge", "1.12.2", dependencies = listOf( + "mixinbooter" to "https://github.com/CleanroomMC/MixinBooter/releases/download/9.3/mixinbooter-9.3.jar" + )), + SmokeTest.Config("forge", "1.8.9", dependencies = listOf( + "mixinbooter" to "https://github.com/CleanroomMC/MixinBooter/releases/download/9.3/mixinbooter-9.3.jar" + )), + SmokeTest.Config("forge", "1.7.10", dependencies = listOf( + "unimixins" to "https://github.com/LegacyModdingMC/UniMixins/releases/download/0.1.19/+unimixins-all-1.7.10-0.1.19.jar" + )), override fun toString(): String { val result = StringBuilder() @@ -691,61 +753,7 @@ val smokeTest = tasks.register("smokeTest") { 21 to "java-runtime-delta", 8 to "jre-legacy" ) - if (config.jvmVersion != null) - extraArgs.add("--jvm=${mainDir}/jvm/${jvmVersionMap[config.jvmVersion]}/bin/java") - - if (config.extraArgs != null) - extraArgs.addAll(config.extraArgs) - - val command = arrayOf( - "${project.rootDir}/.gradle/python/bin/portablemc", - "--main-dir", mainDir, - "--work-dir", workDir, - "start", config.versionString, - *extraArgs.toTypedArray(), - "--jvm-args=-DzumeGradle.auditAndExit=true", - ) - - ProcessBuilder(*command, "--dry") - .inheritIO() - .start() - .waitFor() - - val process = ProcessBuilder(*command) - .inheritIO() - .start() - - var passed = false - - if (process.waitFor(30, TimeUnit.SECONDS)) { - file(latestLog).also { logFile -> - if (logFile.exists()) { - logFile.reader().use { reader -> - reader.forEachLine { line -> - if (line.endsWith("ZumeGradle audit passed")) - passed = true - } - } - } - } - } else { - process.destroy() - } - - if (passed) { - logger.info("Smoke test passed for config:\n${config}") - } else { - logger.error("Smoke test failed for config:\n${config}") - failures.add(config) - } - } - - if (failures.isNotEmpty()) { - logger.error("[{\n${failures.joinToString("}, {\n")}}]") - error("One or more tests failed. See logs for more details.") - } - - logger.info("All tests passed.") + ).test() } } //endregion diff --git a/buildSrc/src/main/kotlin/dev/nolij/zumegradle/SmokeTest.kt b/buildSrc/src/main/kotlin/dev/nolij/zumegradle/SmokeTest.kt new file mode 100644 index 0000000..69a0ed9 --- /dev/null +++ b/buildSrc/src/main/kotlin/dev/nolij/zumegradle/SmokeTest.kt @@ -0,0 +1,229 @@ +@file:OptIn(ExperimentalPathApi::class) + +package dev.nolij.zumegradle + +import org.gradle.api.logging.Logger +import java.io.File +import java.io.FileOutputStream +import java.net.URL +import java.nio.file.Files +import java.util.* +import kotlin.io.path.* + +fun sleep(millis: Long) { + Thread.sleep(millis) +} + +class SmokeTest( + private val logger: Logger, + private val portableMCBinary: String, + private val modFile: File, + private val mainDir: String, + private val workDir: String, + private val maxThreads: Int, + private val threadTimeout: Long, + private val configs: List +) { + + data class Config( + val modLoader: String, + val mcVersion: String, + val loaderVersion: String? = null, + val jvmVersion: Int? = null, + val extraArgs: List? = null, + val dependencies: List>? = null, + ) { + val versionString: String get() = + if (loaderVersion != null) + "${modLoader}:${mcVersion}:${loaderVersion}" + else + "${modLoader}:${mcVersion}" + + override fun toString(): String { + val result = StringBuilder() + + result.appendLine("modLoader=${modLoader}") + result.appendLine("mcVersion=${mcVersion}") + result.appendLine("loaderVersion=${loaderVersion}") + result.appendLine("jvmVersion=${jvmVersion}") + result.appendLine("extraArgs=[${extraArgs?.joinToString(", ") ?: ""}]") + result.appendLine("mods=[${dependencies?.joinToString(", ") { (name, _) -> name } ?: ""}]") + + return result.toString() + } + } + + private enum class ThreadState { + PENDING, + RUNNING, + TIMED_OUT, + READY, + PASSED, + FAILED, + } + + private inner class Thread(val config: Config) { + private val name: String = config.hashCode().toUInt().toString(16) + private val instanceDir = "${workDir}/${name}" + private val modsDir = "${instanceDir}/mods" + private val logDir = "${instanceDir}/logs/latest.log" + private val logFile = File(logDir) + private val command: Array + + private var process: Process? = null + + private var startTimestamp: Long? = null + + val isAlive: Boolean get() = process?.isAlive == true + private val isTimedOut: Boolean get() = + if (isAlive) + System.nanoTime() - startTimestamp!! > threadTimeout + else + false + + private var finalState: ThreadState? = null + val finished: Boolean get() = finalState != null + val state: ThreadState + get() { + return finalState ?: + if (startTimestamp == null) ThreadState.PENDING + else if (isTimedOut) ThreadState.TIMED_OUT + else if (isAlive) ThreadState.RUNNING + else ThreadState.READY + } + + init { + Path(instanceDir).also { path -> + if (!path.exists()) + path.createDirectories() + } + + Path(modsDir).also { modsPath -> + if (modsPath.exists()) + modsPath.deleteRecursively() + modsPath.createDirectories() + } + + Path(logDir).also { logPath -> + logPath.deleteIfExists() + logPath.parent.also { logsPath -> + if (!logsPath.exists()) + logsPath.createDirectories() + } + } + + config.dependencies?.forEach { (name, urlString) -> + URL(urlString).openStream().use { inputStream -> + FileOutputStream("${modsDir}/${name}.jar").use(inputStream::transferTo) + } + } + + Files.copy(modFile.toPath(), Path("${modsDir}/${modFile.name}")) + + val extraArgs = arrayListOf() + + val jvmVersionMap = mapOf( + 17 to "java-runtime-gamma", + 21 to "java-runtime-delta", + 8 to "jre-legacy" + ) + if (config.jvmVersion != null) + extraArgs.add("--jvm=${mainDir}/jvm/${jvmVersionMap[config.jvmVersion]!!}/bin/java") + + if (config.extraArgs != null) + extraArgs.addAll(config.extraArgs) + + command = arrayOf( + portableMCBinary, + "--main-dir", mainDir, + "--work-dir", instanceDir, + "start", config.versionString, + *extraArgs.toTypedArray(), + "--jvm-args=-DzumeGradle.auditAndExit=true", + ) + + ProcessBuilder(*command, "--dry") + .inheritIO() + .start() + .waitFor() + } + + fun start() { + if (state != ThreadState.PENDING) + error("Thread already started!") + + startTimestamp = System.nanoTime() + process = ProcessBuilder(*command) + .inheritIO() + .start() + } + + fun update() { + var passed = false + + when (state) { + ThreadState.TIMED_OUT -> process!!.destroyForcibly() + ThreadState.READY -> { + if (logFile.exists()) { + logFile.reader().use { reader -> + reader.forEachLine { line -> + if (line.endsWith("ZumeGradle audit passed")) + passed = true + } + } + } + } + else -> return + } + + if (passed) { + println("Smoke test passed for config:\n${config}") + finalState = ThreadState.PASSED + } else { + logger.error("Smoke test failed for config:\n${config}") + finalState = ThreadState.FAILED + } + + printThreads() + } + } + + private val threads = ArrayList() + + private fun printThreads() { + println(""" + > TOTAL: ${threads.filter { thread -> thread.finished }.size}/${configs.size} + > RUNNING: ${threads.filter { thread -> thread.state == ThreadState.RUNNING }.size}/${maxThreads} + > PASSED: ${threads.filter { thread -> thread.state == ThreadState.PASSED }.size} + > FAILED: ${threads.filter { thread -> thread.state == ThreadState.FAILED }.size} + """.trimIndent()) + } + + fun test() { + println("Setting up instances...") + configs.forEach { config -> + threads.add(Thread(config)) + } + + printThreads() + + do { + while (threads.count { thread -> thread.isAlive } < maxThreads) + threads.firstOrNull { thread -> thread.state == ThreadState.PENDING }?.start() ?: break + sleep(500L) + threads.forEach(Thread::update) + } while (threads.any { thread -> !thread.finished }) + + val failedConfigs = threads + .filter { thread -> thread.state == ThreadState.FAILED } + .map { thread -> thread.config } + + if (failedConfigs.isNotEmpty()) { + logger.error("[{\n${failedConfigs.joinToString("}, {\n")}}]") + error("One or more tests failed. See logs for more details.") + } + + println("All tests passed.") + } + +} \ No newline at end of file