test: Refactor test utilities, fix conformance (#476)

# Refactor test utilities, fix conformance

- Add `test-utils` module to streamline test cases
- Rename module `kotlin-sdk-test` -> `integration-test`
- Move test utilities for managing processes, ports, and TypeScript
execution logic
- Make conformance tests work by specifying conformance package version
(0.1.8)

## Motivation and Context
Reuse test infrastructure code, resurrect conformance tests

## How Has This Been Tested?
CI, locally

## Breaking Changes
No

## Types of changes
- [x] Bug fix (non-breaking change which fixes an issue)
- [x] Tests update

## Checklist
<!-- Go over all the following points, and put an `x` in all the boxes
that apply. -->
- [x] I have read the [MCP
Documentation](https://modelcontextprotocol.io)
- [x] My code follows the repository's style guidelines
- [x] New and existing tests pass locally
- [ ] I have added appropriate error handling
- [ ] I have added or updated documentation as needed
This commit is contained in:
Konstantin Pavlov 2026-01-10 16:51:15 +02:00 • committed by GitHub
commit 29d34b8e48
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 243 additions and 39 deletions

View file

@ -0,0 +1,6 @@
package io.modelcontextprotocol.kotlin.test.utils
import org.junit.jupiter.api.condition.DisabledIfEnvironmentVariable
@DisabledIfEnvironmentVariable(named = "CI", matches = "true")
public annotation class DisabledOnCI

View file

@ -0,0 +1,98 @@
package io.modelcontextprotocol.kotlin.test.utils
import org.junit.jupiter.api.extension.ExtendWith
import org.junit.jupiter.api.extension.ExtensionContext
import org.junit.jupiter.api.extension.InvocationInterceptor
import org.junit.jupiter.api.extension.InvocationInterceptor.Invocation
import org.junit.jupiter.api.extension.ReflectiveInvocationContext
import org.opentest4j.TestAbortedException
import java.lang.reflect.AnnotatedElement
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.util.Optional
@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@ExtendWith(RetryExtension::class)
public annotation class Retry(val times: Int = 3, val delayMs: Long = 1000)
public class RetryExtension : InvocationInterceptor {
override fun interceptTestMethod(
invocation: Invocation<Void>,
invocationContext: ReflectiveInvocationContext<Method>,
extensionContext: ExtensionContext,
) {
executeWithRetry(invocation, extensionContext)
}
private fun resolveRetryAnnotation(extensionContext: ExtensionContext): Retry? {
val classAnn = extensionContext.testClass.flatMap { findRetry(it) }
return classAnn.orElse(null)
}
private fun findRetry(element: AnnotatedElement): Optional<Retry> =
Optional.ofNullable(element.getAnnotation(Retry::class.java))
private fun executeWithRetry(invocation: Invocation<Void>, extensionContext: ExtensionContext) {
val retry = resolveRetryAnnotation(extensionContext)
if (retry == null || retry.times <= 1) {
invocation.proceed()
return
}
val maxAttempts = retry.times
val delay = retry.delayMs
var lastError: Throwable? = null
for (attempt in 1..maxAttempts) {
if (attempt > 1 && delay > 0) {
try {
Thread.sleep(delay)
} catch (_: InterruptedException) {
Thread.currentThread().interrupt()
break
}
}
try {
if (attempt == 1) {
invocation.proceed()
} else {
val instance = extensionContext.requiredTestInstance
val testMethod = extensionContext.requiredTestMethod
testMethod.isAccessible = true
testMethod.invoke(instance)
}
return
} catch (t: Throwable) {
if (t is TestAbortedException) throw t
lastError = if (t is InvocationTargetException) t.targetException ?: t else t
if (attempt == maxAttempts) {
println(
"[Retry] Giving up after $attempt attempts for ${
describeTest(
extensionContext,
)
}: ${lastError.message}",
)
throw lastError
}
println(
"[Retry] Failure on attempt $attempt/$maxAttempts for ${
describeTest(
extensionContext,
)
}: ${lastError.message}",
)
}
}
throw lastError ?: IllegalStateException("Unexpected state in retry logic")
}
private fun describeTest(ctx: ExtensionContext): String {
val methodName = ctx.testMethod.map(Method::getName).orElse("<unknown>")
val className = ctx.testClass.map { it.name }.orElse("<unknown>")
return "$className#$methodName"
}
}

View file

@ -0,0 +1,62 @@
package io.modelcontextprotocol.kotlin.test.utils
import java.io.File
/**
* Component for running arbitrary TypeScript files using npx tsx.
*/
public object TypeScriptRunner {
/**
* Runs a TypeScript file using npx tsx.
*
* @param typescriptDir The working directory for the process
* @param scriptPath The path to the TypeScript file relative to [typescriptDir] or absolute
* @param env Environment variables for the process
* @param redirectErrorStream Whether to redirect error stream to output stream
* @param logPrefix Prefix for log lines
* @return The started Process
*/
public fun run(
typescriptDir: File,
scriptPath: String,
env: Map<String, String> = emptyMap(),
redirectErrorStream: Boolean = false,
logPrefix: String = "TS-RUNNER",
): Process {
val pb = ProcessBuilder("npx", "tsx", scriptPath)
pb.directory(typescriptDir)
env.forEach { (k, v) -> pb.environment()[k] = v }
pb.redirectErrorStream(redirectErrorStream)
val proc = pb.start()
if (redirectErrorStream) {
createProcessOutputReader(proc, logPrefix).start()
} else {
createProcessErrorReader(proc, logPrefix).start()
}
return proc
}
/**
* Installs npm dependencies in the specified TypeScript directory.
*
* @param typescriptDir The directory containing package.json
* @throws RuntimeException if npm install fails
*/
public fun installDependencies(typescriptDir: File) {
require(typescriptDir.isDirectory()) { "Type script directory does not exist" }
println("Installing TypeScript dependencies in ${typescriptDir.absolutePath}")
val pb = ProcessBuilder("npm", "install")
pb.directory(typescriptDir)
pb.redirectErrorStream(true)
val proc = pb.start()
proc.inputStream.bufferedReader().useLines { lines ->
lines.forEach { println("[NPM] $it") }
}
val exitCode = proc.waitFor()
require(exitCode == 0) { "npm install failed with exit code $exitCode" }
}
}

View file

@ -0,0 +1,3 @@
package io.modelcontextprotocol.kotlin.test.utils
public val isWindows: Boolean = System.getProperty("os.name").lowercase().contains("windows")

View file

@ -0,0 +1,13 @@
package io.modelcontextprotocol.kotlin.test.utils
public fun createTeeProcessBuilder(): ProcessBuilder = if (isWindows) {
ProcessBuilder("cmd", "/c", "findstr", "^")
} else {
ProcessBuilder("tee")
}
public fun createSleepyProcessBuilder(): ProcessBuilder = if (isWindows) {
ProcessBuilder("cmd", "/c", "ping -n 2 127.0.0.1 > nul && echo simulated error 1>&2 && exit 1")
} else {
ProcessBuilder("sh", "-c", "sleep 1 && echo 'simulated error' >&2 && exit 1")
}

View file

@ -0,0 +1,113 @@
package io.modelcontextprotocol.kotlin.test.utils
import org.awaitility.kotlin.await
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.TimeUnit
/**
* Kills any process currently using the specified port.
*
* @param port The port number to free up
*/
public fun killProcessOnPort(port: Int) {
val killCommand = if (isWindows) {
listOf(
"cmd.exe",
"/c",
"netstat -ano | findstr :$port | for /f \"tokens=5\" %a in ('more') do taskkill /F /PID %a 2>nul || echo No process found",
)
} else {
listOf("bash", "-c", "lsof -ti:$port | xargs kill -9 2>/dev/null || true")
}
ProcessBuilder(killCommand).start().waitFor()
}
/**
* Waits for a port to become available (indicating server is ready).
*
* @param host The hostname to check (default: "localhost")
* @param port The port number to check
* @param timeoutSeconds Maximum time to wait in seconds (default: 10)
* @return true if port became available, false if timeout occurred
*/
public fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Long = 10): Boolean = try {
await.atMost(timeoutSeconds, TimeUnit.SECONDS)
.pollDelay(200, TimeUnit.MILLISECONDS)
.pollInterval(100, TimeUnit.MILLISECONDS)
.until {
try {
Socket(host, port).use { true }
} catch (_: Exception) {
false
}
}
true
} catch (_: Exception) {
false
}
/**
* Stops the given process.
*
* Attempts graceful shutdown first, then forces termination if necessary.
*/
public fun stopProcess(process: Process) {
process.destroy()
if (!process.waitFor(1, TimeUnit.SECONDS)) {
process.destroyForcibly()
}
}
/**
* Finds an available port by opening and closing a ServerSocket.
*
* @return An available port number
*/
public fun findFreePort(): Int {
ServerSocket(0).use { socket ->
return socket.localPort
}
}
/**
* Creates a daemon thread that reads and logs process output.
*
* @param process The process to read from
* @param prefix Log prefix for output lines
* @return The created Thread (already started)
*/
public fun createProcessOutputReader(process: Process, prefix: String): Thread {
val thread = Thread {
try {
process.inputStream.bufferedReader().useLines { lines ->
lines.forEach { println("[$prefix] $it") }
}
} catch (e: Exception) {
println("Warning: Error reading process output: ${e.message}")
}
}
thread.isDaemon = true
return thread
}
/**
* Creates a daemon thread that reads and logs process error output.
*
* @param process The process to read from
* @param prefix Log prefix for error lines
* @return The created Thread (already started)
*/
public fun createProcessErrorReader(process: Process, prefix: String): Thread {
val thread = Thread {
try {
process.errorStream.bufferedReader().useLines { lines ->
lines.forEach { println("[$prefix][err] $it") }
}
} catch (e: Exception) {
println("Warning: Error reading process error stream: ${e.message}")
}
}
thread.isDaemon = true
return thread
}