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:
parent
6fee82ff1a
commit
29d34b8e48
6 changed files with 243 additions and 39 deletions
|
|
@ -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
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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" }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
package io.modelcontextprotocol.kotlin.test.utils
|
||||
|
||||
public val isWindows: Boolean = System.getProperty("os.name").lowercase().contains("windows")
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in a new issue