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:
Konstantin Pavlov 2026-01-11 09:38:13 +02:00 • committed by GitHub
commit 6ad9bf2122
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 419 additions and 441 deletions

View file

@ -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)
}
}
}
}

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

@ -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()
}
}