For JS browser tests, is there a way to hook into ...
# test
m
For JS browser tests, is there a way to hook into the test infrastructure to start/stop a JVM server during the tests? Is anything like this possible?
l
to tackle this we have written a simple one file module which holds an actual ktor server, and then we have overridden the
allTests
,
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 that
👀 1
m
Nice! Are you using `doFirst {}`/`doLast {}` ? Or something else?
l
Yes, adding the script below, it should help, you have have to ignore stuff related to our local setup as it's not cleaned up. I might just publish whole thing when i find time as it looked like a problem to which atleast I couldn't find any other solution.
Copy code
import 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}" } ?: ""}")
}
kodee loving 1
m
Available port was also a concern indeed. Thanks for providing this 🙏
Thanks for sharing the snippet! I turned it into this if you're curious. It uses a
BuildService
and in-process server. Looks OK so far, will see how it behaves in CI
o
JIC, here is my take on how to do this using `BuildService`: https://github.com/whyoleg/cryptography-kotlin/tree/main/testtool/plugin/src/main/kotlin It doesn't require additional tasks and work pretty well at my side
👍 1
m
I contemplated
doFirst {}
but I was unsure about the order of task action execution, so I added an extra task. Might not be required indeed
o
In my case, the service will be created on the first
serverProvider.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 started
👍 1
m
doFirst {}
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 sweaty
😁 1
But that's just me overthinking this.
With the additional task, one drawback is that the server may be called very early depending how Gradle decides to schedule the different tasks.