fix(integration-test): refactor integration test infrastructure, fix and enable tests with ts-client (#478)
# Refactor integration test infrastructure, fix and enable tests with ts-client - Refactor TypeScript integration test infrastructure to support parallel execution and real I/O operations. - Extract reusable TypeScriptClient component - Migrate some tests from `runTest` to `runBlocking` for tests that spawn actual processes and communicate over real network connections. ## Key Changes - Extract `TypeScriptClient` class for managing TS client processes - Move client scripts to integration-test/src/jvmTest/typescript/client/ - Migrate typescript: `myClient.ts`(2) → `sse-client.ts`, `stdio-client.ts` - Automate TypeScript dependency installation in static initializer - Enable JUnit parallel execution (dynamic strategy, concurrent mode) with test timeout of 2 minutes - Replace `mutableListOf` with `ConcurrentLinkedQueue` for thread-safe notification handling - Migrate integration tests from `runTest` to `runBlocking` **Rationale:** Integration tests that spawn real TypeScript processes, establish actual HTTP/SSE connections, and manage server lifecycles require real-time I/O behavior. The runTest framework's virtual time is designed for fast unit tests with simulated timing, not for integration scenarios where external processes and network communication must occur in real time. Parallel execution requires thread-safe data structures (ConcurrentLinkedQueue) to safely collect notifications from multiple concurrent test instances. - Add `EmbeddedServer.actualPort()` extension to get bound port - Standardize kotlin.time.Duration usage across test utilities - Update processUtils.stopProcess() to accept Duration parameter - Replace RuntimeException checks with idiomatic check() calls - Revert `--rerun-tasks` flag to Gradle workflows for reliable builds ## Motivation and Context Integration tests with typescript client are now broken. Enabling parallel test execution will force writing thread-safe independent tests and will highlight problems faster. ## How Has This Been Tested? CI ## Breaking Changes <!-- Will users need to update their code or configurations? --> ## Types of changes <!-- What types of changes does your code introduce? Put an `x` in all the boxes that apply: --> - [x] CI Update - [x] Test Fix ## 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 ## Additional context Follow up for #477
This commit is contained in:
parent
658b370c87
commit
6ad9bf2122
3 changed files with 419 additions and 441 deletions
|
|
@ -24,6 +24,7 @@ public object TypeScriptRunner {
|
|||
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)
|
||||
|
|
@ -34,10 +35,12 @@ public object TypeScriptRunner {
|
|||
|
||||
val proc = pb.start()
|
||||
|
||||
if (redirectErrorStream) {
|
||||
createProcessOutputReader(proc, logPrefix).start()
|
||||
} else {
|
||||
createProcessErrorReader(proc, logPrefix).start()
|
||||
if (log) {
|
||||
if (redirectErrorStream) {
|
||||
createProcessOutputReader(proc, logPrefix).start()
|
||||
} else {
|
||||
createProcessErrorReader(proc, logPrefix).start()
|
||||
}
|
||||
}
|
||||
|
||||
return proc
|
||||
|
|
@ -50,16 +53,91 @@ public object TypeScriptRunner {
|
|||
* @throws RuntimeException if npm install fails
|
||||
*/
|
||||
public fun installDependencies(typescriptDir: File) {
|
||||
require(typescriptDir.isDirectory()) { "Type script directory does not exist" }
|
||||
require(typescriptDir.isDirectory()) { "Type script directory does not exist: ${typescriptDir.absolutePath}" }
|
||||
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 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
|
||||
}
|
||||
}
|
||||
val exitCode = proc.waitFor()
|
||||
require(exitCode == 0) { "npm install failed with exit code $exitCode" }
|
||||
|
||||
// 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
|
||||
|
||||
throw RuntimeException("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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -4,6 +4,8 @@ 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.
|
||||
|
|
@ -52,9 +54,9 @@ public fun waitForPort(host: String = "localhost", port: Int, timeoutSeconds: Lo
|
|||
*
|
||||
* Attempts graceful shutdown first, then forces termination if necessary.
|
||||
*/
|
||||
public fun stopProcess(process: Process) {
|
||||
public fun stopProcess(process: Process, wait: Duration = 1.seconds) {
|
||||
process.destroy()
|
||||
if (!process.waitFor(1, TimeUnit.SECONDS)) {
|
||||
if (!process.waitFor(wait.inWholeMilliseconds, TimeUnit.MILLISECONDS)) {
|
||||
process.destroyForcibly()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue