mbonnin
07/26/2025, 1:17 PMLuv Kumar
07/26/2025, 2:44 PMallTests
, iosSimulatorArm64Test
, jsBrowserTest
, testDebugUnitTest
to start stop the server before and these tasks.
we don't do this only js, but for all 3 platforms as we need something common so tests can be written in a neat way within commonTest. Only issue with above you might face is if you test things like timeout, or run background process and also test them, you have to be careful while running testing those as tests would use virtual time and http will work in real world time. Apart from that, its been working beautifully for us since i have written thatmbonnin
07/26/2025, 2:56 PMLuv Kumar
07/26/2025, 3:07 PMimport com.flock.omni.buildlogic.const
import kotlin.concurrent.thread
val testServerPort = 18923
val logPrefix = "[Test Ktor Server]"
tasks.register("startTestServerBackground") {
group = const.DevLocalTasks
description = "Start Ktor test server in background for testing"
dependsOn(":test-server:build", ":test-server:distTar")
doLast {
if (!isPortAvailable(testServerPort)) {
log("Port $testServerPort is already in use. Attempting to kill...")
val killed = findAndKillTestServerByPort(testServerPort)
log("Killed? $killed")
if (!killed)
throw GradleException("Port $testServerPort is already in use. Please stop the running service first.")
}
val process = startTestServer()
project.ext.set("testServerProcess", process)
/**
* Un-comment/Comment below to see/hide test server logs when running tests via terminal
*/
thread(name = "server-stdout") {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { println("[TestServer] $it") }
}
}
thread(name = "server-stderr") {
process.errorStream.bufferedReader().useLines { lines ->
lines.forEach { println("[TestServer:ERR] $it") }
}
}
waitForServer(testServerPort, process)
}
}
tasks.register("stopTestServerBackground") {
group = const.DevLocalTasks
description = "Stop background Ktor test server"
doLast {
var killed = killStoredProcess()
if (!killed) {
killed = findAndKillTestServerByPort(testServerPort)
}
log("Ktor Test server cleanup completed")
}
}
// Configure test tasks to depend on server
val testTasksWithServer = listOf("allTests", "iosSimulatorArm64Test", "jsBrowserTest", "testDebugUnitTest")
testTasksWithServer.forEach { taskName ->
tasks.matching { it.name == taskName }.configureEach {
dependsOn("startTestServerBackground")
finalizedBy("stopTestServerBackground")
}
}
private fun isPortAvailable(port: Int): Boolean {
return try {
java.net.Socket("localhost", port).use {
false
}
} catch (_: java.net.ConnectException) {
true
}
}
private fun startTestServer(): Process {
val distDir = rootProject.project(":test-server").layout.buildDirectory.get().asFile.resolve("distributions")
val tarFile = distDir.resolve("test-server.tar")
if (tarFile.exists()) {
log("Starting test server using extracted distribution...")
val extractDir = distDir.resolve("extracted").apply {
if (exists()) deleteRecursively()
mkdirs()
}
ProcessBuilder("tar", "-xf", tarFile.absolutePath, "-C", extractDir.absolutePath).start().waitFor()
val startScript = extractDir.resolve("test-server/bin/test-server").apply {
setExecutable(true)
}
return ProcessBuilder(startScript.absolutePath)
.directory(rootProject.projectDir)
.redirectErrorStream(true)
.start()
.also { log("Started test server from distribution with PID ${it.pid()}") }
} else {
log("Distribution not found, falling back to gradle run...")
return ProcessBuilder("./gradlew", ":test-server:run", "--no-daemon", "--console=plain")
.directory(rootProject.projectDir)
.start()
.also { log("Started test server via gradle run with PID ${it.pid()}") }
}
}
private fun waitForServer(port: Int, process: Process, maxAttempts: Int = 10, delayMs: Long = 500) {
repeat(maxAttempts) { attempt ->
try {
java.net.Socket("localhost", port).use {
log("Connected to test server on attempt $attempt")
return
}
} catch (e: Exception) {
log("Attempt $attempt: Server not up yet (${e.message})")
Thread.sleep(delayMs)
}
}
process.destroyForcibly()
throw GradleException("Unable to connect to test server on port $port")
}
private fun killStoredProcess(): Boolean {
return try {
val process = project.ext.get("testServerProcess") as? Process
if (process?.isAlive == true) {
process.destroyForcibly()
log("Killed stored test server process (PID: ${process.pid()})")
true
} else {
log("No stored test server process found or already stopped")
false
}
} catch (e: Exception) {
log("No stored test server process found")
false
}
}
private fun findAndKillTestServerByPort(port: Int): Boolean {
return try {
java.net.Socket("localhost", port).use {
log("Port $port is in use, checking process info...")
val lsof = ProcessBuilder("lsof", "-tiTCP:$port", "-sTCP:LISTEN").start()
lsof.waitFor()
val pid = lsof.inputStream.bufferedReader().readText().trim()
if (pid.isBlank()) return false
val ps = ProcessBuilder("ps", "-p", pid, "-o", "command=").start()
ps.waitFor()
val commandLine = ps.inputStream.bufferedReader().readText().trim()
log("Process using port: $commandLine")
if (commandLine.contains("TestServerKt") || commandLine.contains("test-server")) {
log("Identified test server. Killing PID $pid...")
ProcessBuilder("kill", pid).start().waitFor()
log("Successfully killed test server (PID $pid)")
true
} else {
log("Process on port $port is not our test server. Skipping kill.")
false
}
}
} catch (e: java.net.ConnectException) {
log("Port $port is not in use")
false
} catch (e: Exception) {
log("Failed to check or kill process: ${e.message}")
false
}
}
private fun log(msg: String, ex: Throwable? = null) {
println("$logPrefix $msg.${ex?.let { " Error: ${it.message}" } ?: ""}")
}
mbonnin
07/26/2025, 3:35 PMmbonnin
07/29/2025, 1:31 PMBuildService
and in-process server. Looks OK so far, will see how it behaves in CIOleg Yukhnevich
07/29/2025, 2:06 PMmbonnin
07/29/2025, 2:10 PMdoFirst {}
but I was unsure about the order of task action execution, so I added an extra task. Might not be required indeedOleg Yukhnevich
07/29/2025, 2:13 PMserverProvider.get()
call and will be closed (service.close()
will be automatically called) when all tasks that are registered with usesService(serverProvider)
are completed (if I remember correctly)
It's for sure closed after the command is finished 🙂
+ also if the task is skipped, no server will be startedmbonnin
07/29/2025, 2:14 PMdoFirst {}
always makes me itchy, I have scars from variable being captured by the closure and what not. Just thinking at all the work that Gradle has to do to serialize and fingerprint this bytecode is making me sweatymbonnin
07/29/2025, 2:15 PMmbonnin
07/29/2025, 2:15 PM