refactor: Refactor serialization tests (#527)

1. Migrate test-utils module to multiplatform, add serialization
utilities

- Migrated `test-utils` to support Kotlin Multiplatform (JVM, JS,
Native).
- Added `verifySerialization`, `verifyDeserialization`,
`verifySerializationRoundTrip` utilities to streamline and validate JSON
serialization/deserialization

2. Refactor serialization tests to use test serialization utilities

- Updated serialization tests across the project to use
`verifySerialization` and `verifyDeserialization` utilities.
- Improved consistency and ensured proper round-trip validation for JSON
deserialization.

3. Remove some detekt baseline files and suppress `LongMethod` findings

    - Deleted detekt baseline XML files for test sources.
- Suppressed `LongMethod` findings in relevant tests using `@Suppress`
annotations.
- Replaced empty method implementations with explicit `// noop` comments
for clarity.
    - Adjusted min test coverage threshold in `build.gradle.kts`.

## Motivation and Context
To improve serialization testing

## How Has This Been Tested?
Unit tests

## Breaking Changes
No

## Types of changes

- [ ] Bug fix (non-breaking change which fixes an issue)
- [x] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [x] Refactoring

## 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-02-17 12:16:02 +02:00 • committed by GitHub
commit 367618d109
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 858 additions and 559 deletions

View file

@ -0,0 +1,17 @@
package io.modelcontextprotocol.kotlin.test.utils
import io.ktor.server.engine.EmbeddedServer
/**
* Retrieves the actual port that the embedded server is bound to.
*
* This function resolves the server's connectors and retrieves the port
* from the single resolved connector. It is useful for obtaining the
* port dynamically in cases where the server is bound to a random port.
*
* Note: This method assumes the server has exactly one resolved connector.
*
* @receiver The embedded server instance
* @return The port number the server is bound to
*/
public suspend fun EmbeddedServer<*, *>.actualPort(): Int = engine.resolvedConnectors().single().port

View file

@ -0,0 +1,88 @@
package io.modelcontextprotocol.kotlin.test.utils
import io.kotest.assertions.json.shouldEqualJson
import io.kotest.assertions.withClue
import io.kotest.matchers.shouldBe
import kotlinx.serialization.SerializationException
import kotlinx.serialization.json.Json
import org.intellij.lang.annotations.Language
import kotlin.test.fail
/**
* Verifies the serialization and deserialization process for a given object, ensuring:
* - The object can be serialized to JSON.
* - The serialized JSON matches the expected JSON.
* - The serialized JSON can be deserialized back into the original object.
*
* This is especially useful for round-trip serialization tests.
*
* @param T The type of the object to be verified.
* @param value The object to be serialized and deserialized.
* @param json The JSON serializer/deserializer used for the serialization process.
* @param expectedJson A lambda returning the expected JSON string for comparison purposes.
*/
public inline fun <reified T> verifySerialization(value: T, json: Json, expectedJson: () -> String) {
verifySerialization(value, json, expectedJson.invoke())
}
/**
* Verifies the serialization and deserialization process for a given object, ensuring:
* - The object can be serialized to JSON.
* - The serialized JSON matches the expected JSON.
* - The serialized JSON can be deserialized back into the original object.
*
* This is especially useful for round-trip serialization tests.
*
* @param T The type of the object being tested for serialization and deserialization.
* @param value The object to be serialized and deserialized.
* @param json The JSON serialization/deserialization processor.
* @param expectedJson The expected JSON string to compare against the serialized output.
*/
public inline fun <reified T> verifySerialization(value: T, json: Json, @Language("json") expectedJson: String) {
val jsonString = json.encodeToString(value)
jsonString shouldEqualJson expectedJson
val parsedObject: T = try {
json.decodeFromString(jsonString)
} catch (e: SerializationException) {
fail("Failed to parse generated json string: ${e.message}:\n$value", e)
}
withClue("Parsed object is not equal to original value") {
parsedObject shouldBe value
}
}
/**
* Verifies the deserialization of a JSON payload into an object of the specified type,
* and ensures that the reverse serialization process returns the same JSON payload.
*
* This is especially useful for round-trip serialization tests.
*
* @param T The type of the object being deserialized from the JSON payload.
* @param json The JSON processor used for deserialization and serialization.
* @param payload The JSON string to deserialize into an object and verify against.
* @return The deserialized object of type T.
*/
public inline fun <reified T> verifyDeserialization(json: Json, @Language("json") payload: String): T {
val value: T = json.decodeFromString<T>(payload)
verifySerialization(value = value, json = json, expectedJson = payload)
return value
}
/**
* Verifies the round-trip serialization and deserialization process for a given object.
* This method ensures that:
* - The object can be serialized into a JSON string.
* - The serialized JSON string can be deserialized back into an equivalent object.
*
* This is particularly useful for testing the integrity of serialization logic.
*
* @param T The type of the object being tested for serialization and deserialization.
* @param value The object to be serialized and deserialized.
* @param json The JSON processor used for the serialization and deserialization process.
*/
public inline fun <reified T> verifySerializationRoundTrip(value: T, json: Json) {
val jsonString: String = json.encodeToString(value)
verifySerialization(value = value, json = json, expectedJson = jsonString)
}

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,99 @@
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)
@Suppress("ForbiddenVoid") // needed for Java API interop
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 ?: error("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,146 @@
package io.modelcontextprotocol.kotlin.test.utils
import org.junit.jupiter.api.fail
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
*/
@Suppress("LongParameterList")
public fun run(
typescriptDir: File,
scriptPath: String,
arguments: List<String> = emptyList(),
env: Map<String, String> = emptyMap(),
redirectErrorStream: Boolean = false,
logPrefix: String = "TS-RUNNER",
log: Boolean = true,
): Process {
val command = mutableListOf("npx", "tsx", scriptPath)
command.addAll(arguments)
val pb = ProcessBuilder(command)
pb.directory(typescriptDir)
env.forEach { (k, v) -> pb.environment()[k] = v }
pb.redirectErrorStream(redirectErrorStream)
val proc = pb.start()
if (log) {
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
*/
@Suppress("ReturnCount")
public fun installDependencies(typescriptDir: File) {
require(typescriptDir.isDirectory()) { "Type script directory does not exist: ${typescriptDir.absolutePath}" }
println("Installing TypeScript dependencies in ${typescriptDir.absolutePath}")
val packageJson = File(typescriptDir, "package.json")
val content = if (packageJson.exists()) packageJson.readText() else ""
val hasCatalog = content.contains("catalog:")
val hasWorkspace = content.contains("workspace:")
val hasPnpmWorkspace = File(typescriptDir, "pnpm-workspace.yaml").exists()
if (hasCatalog || hasWorkspace || hasPnpmWorkspace) {
println("Detected pnpm-specific protocols or workspace. Attempting pnpm install...")
if (tryCommand(listOf("pnpm", "install"), typescriptDir) ||
tryCommand(listOf("npx", "pnpm", "install"), typescriptDir)
) {
patchPackageJsonRecursively(typescriptDir, patchProtocols = false)
return
}
}
// Try standard npm install
if (tryCommand(listOf("npm", "install"), typescriptDir)) {
patchPackageJsonRecursively(typescriptDir, patchProtocols = false)
return
}
// If npm fails and we have catalog/workspace, try patching and npm again
println("npm install failed. Patching package.json and trying again...")
patchPackageJsonRecursively(typescriptDir, patchProtocols = true)
if (tryCommand(listOf("npm", "install"), typescriptDir)) return
fail("Failed to install TypeScript dependencies in ${typescriptDir.absolutePath}")
}
private fun tryCommand(command: List<String>, workingDir: File): Boolean {
val fullCommand = if (isWindows) {
listOf("cmd.exe", "/c") + command
} else {
command
}
println("Executing: ${fullCommand.joinToString(" ")}")
return try {
val pb = ProcessBuilder(fullCommand)
pb.directory(workingDir)
pb.redirectErrorStream(true)
val proc = pb.start()
proc.inputStream.bufferedReader().useLines { lines ->
lines.forEach { println("[TS-INSTALL] $it") }
}
proc.waitFor() == 0
} catch (e: Exception) {
println("Command failed: ${e.message}")
false
}
}
private fun patchPackageJsonRecursively(dir: File, patchProtocols: Boolean) {
dir.walkTopDown()
.onEnter { it.name != "node_modules" }
.filter { it.name == "package.json" }
.forEach { file ->
var content = file.readText()
var modified = false
if (patchProtocols) {
val newContent = content
.replace(Regex(""""catalog:[^"]*""""), "\"*\"")
.replace(Regex(""""workspace:[^"]*""""), "\"*\"")
.replace(Regex(""""link:[^"]*""""), "\"*\"")
if (newContent != content) {
content = newContent
modified = true
}
}
// Add entry point if missing and src/index.ts exists to help tsx resolution
val srcIndex = File(file.parentFile, "src/index.ts")
if (srcIndex.exists() && !content.contains("\"exports\"") && !content.contains("\"main\"")) {
println("Adding exports to ${file.absolutePath}")
content = content.replace(Regex("""^\{"""), "{\n \"exports\": { \".\": \"./src/index.ts\" },")
modified = true
}
if (modified) {
file.writeText(content)
}
}
}
}

View file

@ -0,0 +1,5 @@
package io.modelcontextprotocol.kotlin.test.utils
public val isWindows: Boolean = System.getProperty("os.name").lowercase().contains("windows")
public val NPX: String = if (isWindows) "npx.cmd" else "npx"

View file

@ -0,0 +1,45 @@
package io.modelcontextprotocol.kotlin.test.utils
import io.github.oshai.kotlinlogging.KLogger
import java.io.BufferedReader
import java.io.InputStream
import java.io.InputStreamReader
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")
}
/**
* Starts logging the lines from this InputStream to a specified logger.
* Each line from the InputStream is read and logged using the provided logger
* at the debug level, optionally with an additional prefix string appended
* to each logged line. The method runs the logging process in a separate daemon thread.
*
* @param logger The logger used to log the lines from the InputStream.
* @param name The name to assign to the daemon thread handling the logging process.
*/
public fun InputStream.startLogging(logger: KLogger, name: String) {
val stream = this
Thread {
try {
BufferedReader(InputStreamReader(stream)).use { reader ->
reader.lineSequence().forEach { line ->
logger.debug { line }
}
}
} catch (e: Exception) {
logger.trace(e) { "Error reading server test stdout" }
}
}.apply {
this.name = name
isDaemon = true
}.start()
}

View file

@ -0,0 +1,116 @@
package io.modelcontextprotocol.kotlin.test.utils
import org.awaitility.kotlin.await
import java.net.ServerSocket
import java.net.Socket
import java.util.concurrent.TimeUnit
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* 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) {
@Suppress("MaxLineLength")
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, wait: Duration = 1.seconds) {
process.destroy()
if (!process.waitFor(wait.inWholeMilliseconds, TimeUnit.MILLISECONDS)) {
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
}