Skip to content

Commit bfefc85

Browse files
committed
1. Replace Testcontainers JUnit dependency and refactor Docker test setup
- Removed `testcontainers-junit` dependency and replaced usage with direct `testcontainers` library. - Don't build container in Gradle - Upgraded `testcontainers` to the latest version. - Refactored Python A2A server Docker handling into `TestA2APythonServer` object for cleaner test configuration. 2. Enhance Awaitility extensions to support return values - Added support for returning values from `untilAsserted` extensions. - Updated test cases to verify return value functionality. 3. Replace direct assertions with Awaitility in A2A tests - Refactored tests to use Awaitility's coroutine extensions for asserting eventually consistent conditions. - Simplified `sendMessageStreaming`, `resubscribeTask`, and notification configuration logic. 4. Use lazy/cached initialization for Docker availability check
1 parent 9780b59 commit bfefc85

File tree

9 files changed

+166
-117
lines changed

9 files changed

+166
-117
lines changed

.github/pull_request_template.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ Also, please tick the appropriate points in the checklist below.
2020
- [ ] Documentation update
2121
- [ ] Tests improvement
2222
- [ ] Refactoring
23+
- [ ] CI/CD changes
24+
- [ ] Dependencies update
2325

2426
#### Checklist
2527
- [ ] The pull request has a description of the proposed change

a2a/a2a-client/build.gradle.kts

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,4 @@
11
import ai.koog.gradle.publish.maven.Publishing.publishToMaven
2-
import org.gradle.internal.os.OperatingSystem
3-
import org.gradle.kotlin.dsl.support.serviceOf
4-
import java.io.ByteArrayOutputStream
52

63
group = rootProject.group
74
version = rootProject.version
@@ -41,7 +38,7 @@ kotlin {
4138

4239
implementation(libs.ktor.client.cio)
4340
implementation(libs.ktor.client.logging)
44-
implementation(libs.testcontainers.junit)
41+
implementation(libs.testcontainers)
4542
runtimeOnly(libs.logback.classic)
4643
}
4744
}
@@ -57,36 +54,3 @@ kotlin {
5754
}
5855

5956
publishToMaven()
60-
61-
tasks.register<Exec>("dockerBuildTestPythonA2AServer") {
62-
group = "docker"
63-
description = "Build Python A2A test server image"
64-
workingDir = file("../test-python-a2a-server")
65-
commandLine = listOf("docker", "build", "-t", "test-python-a2a-server", ".")
66-
67-
onlyIf {
68-
// do not attempt to check for docker on windows
69-
if (OperatingSystem.current().isWindows) {
70-
return@onlyIf false
71-
}
72-
73-
try {
74-
val buffer = ByteArrayOutputStream()
75-
76-
serviceOf<ExecOperations>().exec {
77-
commandLine = listOf("docker", "--version")
78-
standardOutput = buffer
79-
errorOutput = buffer
80-
}
81-
82-
true
83-
} catch (_: Exception) {
84-
logger.warn("Docker not available. Skipping task 'dockerBuildTestPythonA2AServer'")
85-
86-
false
87-
}
88-
}
89-
}
90-
tasks.named("jvmTest") {
91-
dependsOn("dockerBuildTestPythonA2AServer")
92-
}

a2a/a2a-client/src/jvmTest/kotlin/ai/koog/a2a/client/A2AClientJsonRpcIntegrationTest.kt

Lines changed: 6 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,17 +6,13 @@ import ai.koog.test.utils.DockerAvailableCondition
66
import io.ktor.client.HttpClient
77
import io.ktor.client.plugins.logging.LogLevel
88
import io.ktor.client.plugins.logging.Logging
9-
import kotlinx.coroutines.test.runTest
9+
import kotlinx.coroutines.runBlocking
1010
import org.junit.jupiter.api.AfterAll
1111
import org.junit.jupiter.api.BeforeAll
1212
import org.junit.jupiter.api.TestInstance
1313
import org.junit.jupiter.api.extension.ExtendWith
1414
import org.junit.jupiter.api.parallel.Execution
1515
import org.junit.jupiter.api.parallel.ExecutionMode
16-
import org.testcontainers.containers.GenericContainer
17-
import org.testcontainers.containers.wait.strategy.Wait
18-
import org.testcontainers.junit.jupiter.Container
19-
import org.testcontainers.junit.jupiter.Testcontainers
2016
import kotlin.test.Test
2117
import kotlin.time.Duration.Companion.seconds
2218

@@ -26,17 +22,10 @@ import kotlin.time.Duration.Companion.seconds
2622
* using the JSON-RPC standard.
2723
*/
2824
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
29-
@Testcontainers
3025
@ExtendWith(DockerAvailableCondition::class)
3126
@Execution(ExecutionMode.SAME_THREAD, reason = "Working with the same instance of test server.")
3227
class A2AClientJsonRpcIntegrationTest : BaseA2AProtocolTest() {
33-
companion object {
34-
@Container
35-
val testA2AServer: GenericContainer<*> =
36-
GenericContainer("test-python-a2a-server")
37-
.withExposedPorts(9999)
38-
.waitingFor(Wait.forListeningPort())
39-
}
28+
val testA2AServer = TestA2AServerContainer
4029

4130
override val testTimeout = 10.seconds
4231

@@ -47,14 +36,14 @@ class A2AClientJsonRpcIntegrationTest : BaseA2AProtocolTest() {
4736
}
4837

4938
@Suppress("HttpUrlsUsage")
50-
private val agentUrl by lazy { "http://${testA2AServer.host}:${testA2AServer.getMappedPort(9999)}" }
39+
private val agentUrl by lazy { "http://${testA2AServer.host}:${testA2AServer.port}" }
5140

5241
private lateinit var transport: HttpJSONRPCClientTransport
5342

5443
override lateinit var client: A2AClient
5544

5645
@BeforeAll
57-
fun setUp() = runTest {
46+
fun setUp() = runBlocking {
5847
transport = HttpJSONRPCClientTransport(
5948
url = agentUrl,
6049
baseHttpClient = httpClient
@@ -72,8 +61,9 @@ class A2AClientJsonRpcIntegrationTest : BaseA2AProtocolTest() {
7261
}
7362

7463
@AfterAll
75-
fun tearDown() = runTest {
64+
fun tearDown() {
7665
transport.close()
66+
testA2AServer.shutdown()
7767
}
7868

7969
@Test
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package ai.koog.a2a.client
2+
3+
import org.slf4j.LoggerFactory
4+
import org.testcontainers.containers.GenericContainer
5+
import org.testcontainers.containers.output.Slf4jLogConsumer
6+
import org.testcontainers.containers.wait.strategy.Wait
7+
import org.testcontainers.images.builder.ImageFromDockerfile
8+
import kotlin.io.path.Path
9+
import kotlin.time.Duration.Companion.seconds
10+
import kotlin.time.toJavaDuration
11+
12+
/**
13+
* Object representing a Dockerized test server for Python-based A2A (Agent-to-Agent) communication.
14+
* This server is instantiated using Testcontainers and is primarily utilized for integration testing
15+
* purposes, such as validating JSON-RPC HTTP communication in an A2A client context.
16+
*
17+
* The server is built from a Dockerfile located in the `../test-python-a2a-server` directory and runs
18+
* on a predefined exposed port. It provides runtime flexibility to inspect the server's dynamically
19+
* assigned host and port.
20+
*
21+
* This `object` ensures the server is only started once for use in test environments, with the
22+
* capability of shutting it down after the tests are completed.
23+
*
24+
* The server should be initialized before tests, retrieving its host and port, and
25+
* using them to configure a test client.
26+
*/
27+
object TestA2AServerContainer {
28+
29+
private const val EXPOSED_PORT = 9999
30+
private val STARTUP_TIMEOUT = 20.seconds.toJavaDuration()
31+
32+
private val image =
33+
ImageFromDockerfile("test-python-a2a-server:latest", false) // "false" prevents deleting intermediate images
34+
.withFileFromPath(".", Path("../test-python-a2a-server")) // Specify Dockerfile context path
35+
private val container: GenericContainer<*> =
36+
GenericContainer(image)
37+
.withTmpFs(mapOf("/tmp" to "rw,noexec,size=16m"))
38+
.withExposedPorts(EXPOSED_PORT)
39+
.withReuse(true)
40+
.waitingFor(Wait.forListeningPort())
41+
.withLogConsumer(Slf4jLogConsumer(LoggerFactory.getLogger(TestA2AServerContainer::class.java)))
42+
.withStartupTimeout(STARTUP_TIMEOUT)
43+
44+
init {
45+
container.start()
46+
}
47+
48+
fun shutdown() = runCatching { container.stop() }
49+
50+
val host: String by lazy {
51+
container.host
52+
}
53+
54+
val port: Int by lazy {
55+
container.getMappedPort(EXPOSED_PORT)
56+
}
57+
}

a2a/a2a-test/src/jvmMain/kotlin/ai/koog/a2a/test/BaseA2AProtocolTest.kt

Lines changed: 39 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import ai.koog.a2a.exceptions.A2AInternalErrorException
55
import ai.koog.a2a.model.AgentCapabilities
66
import ai.koog.a2a.model.AgentCard
77
import ai.koog.a2a.model.AgentSkill
8+
import ai.koog.a2a.model.Event
89
import ai.koog.a2a.model.Message
910
import ai.koog.a2a.model.MessageSendConfiguration
1011
import ai.koog.a2a.model.MessageSendParams
@@ -101,7 +102,7 @@ abstract class BaseA2AProtocolTest {
101102
}
102103

103104
open fun `test get authenticated extended agent card`() = runTest(timeout = testTimeout) {
104-
val request = Request<Nothing?>(data = null)
105+
val request = Request(data = null)
105106

106107
val response = client.getAuthenticatedExtendedAgentCard(request)
107108

@@ -172,10 +173,10 @@ abstract class BaseA2AProtocolTest {
172173

173174
val response = client.sendMessage(request)
174175

175-
response should {
176-
it.id shouldBe request.id
176+
response should { response ->
177+
response.id shouldBe request.id
177178

178-
it.data.shouldBeInstanceOf<Message> {
179+
response.data.shouldBeInstanceOf<Message> {
179180
it.role shouldBe Role.Agent
180181
it.parts shouldBe listOf(TextPart("Hello World"))
181182
it.contextId shouldBe "test-context"
@@ -197,19 +198,23 @@ abstract class BaseA2AProtocolTest {
197198
),
198199
)
199200

200-
val events = client
201-
.sendMessageStreaming(createTaskRequest)
202-
.toList()
203-
.map { it.data }
201+
val events: List<Event> = await.untilAsserted(this) {
202+
val list = client
203+
.sendMessageStreaming(createTaskRequest)
204+
.toList()
205+
.map { it.data }
204206

205-
events shouldHaveSize 3
206-
events[0].shouldBeInstanceOf<Task> {
207-
it.contextId shouldBe "test-context"
208-
it.status should {
207+
list shouldHaveSize 3
208+
return@untilAsserted list
209+
}!!
210+
211+
events[0].shouldBeInstanceOf<Task> { task ->
212+
task.contextId shouldBe "test-context"
213+
task.status should {
209214
it.state shouldBe TaskState.Submitted
210215
}
211216

212-
it.history shouldNotBeNull {
217+
task.history shouldNotBeNull {
213218
this shouldHaveSize 1
214219

215220
this[0] should {
@@ -344,12 +349,15 @@ abstract class BaseA2AProtocolTest {
344349
)
345350
)
346351

347-
val events = client
348-
.resubscribeTask(resubscribeTaskRequest)
349-
.toList()
350-
.map { it.data }
351-
352-
events.shouldNotBeEmpty()
352+
val events =
353+
await.ignoreExceptions().untilAsserted(this) {
354+
val list = client
355+
.resubscribeTask(resubscribeTaskRequest)
356+
.toList()
357+
.map { it.data }
358+
list.shouldNotBeEmpty()
359+
return@untilAsserted list
360+
}!!
353361

354362
events.shouldForAll {
355363
it.shouldBeInstanceOf<TaskStatusUpdateEvent> {
@@ -415,17 +423,21 @@ abstract class BaseA2AProtocolTest {
415423
)
416424
)
417425

418-
val getPushConfigResponse = client.getTaskPushNotificationConfig(getPushConfigRequest)
419-
getPushConfigResponse.data shouldBe pushConfig
426+
await.untilAsserted(this) {
427+
val response = client.getTaskPushNotificationConfig(getPushConfigRequest)
428+
response.data shouldBe pushConfig
429+
}
420430

421431
val listPushConfigRequest = Request(
422432
data = TaskIdParams(
423433
id = taskId,
424434
)
425435
)
426436

427-
val listPushConfigResponse = client.listTaskPushNotificationConfig(listPushConfigRequest)
428-
listPushConfigResponse.data shouldBe listOf(pushConfig)
437+
await.untilAsserted(this) {
438+
val listPushConfigResponse = client.listTaskPushNotificationConfig(listPushConfigRequest)
439+
listPushConfigResponse.data shouldBe listOf(pushConfig)
440+
}
429441

430442
val deletePushConfigRequest = Request(
431443
data = TaskPushNotificationConfigParams(
@@ -436,8 +448,10 @@ abstract class BaseA2AProtocolTest {
436448

437449
client.deleteTaskPushNotificationConfig(deletePushConfigRequest)
438450

439-
shouldThrowExactly<A2AInternalErrorException> {
440-
client.getTaskPushNotificationConfig(getPushConfigRequest)
451+
await.untilAsserted(this) {
452+
shouldThrowExactly<A2AInternalErrorException> {
453+
client.getTaskPushNotificationConfig(getPushConfigRequest)
454+
}
441455
}
442456
}
443457
}

gradle/libs.versions.toml

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ mockito = "5.21.0"
2929
mockk = "1.14.7"
3030
mokksy = "0.5.1"
3131
mysql = "8.0.33"
32-
netty = "4.2.6.Final"
32+
netty = "4.2.9.Final"
3333
okhttp = "5.3.2"
3434
opentelemetry = "1.51.0"
3535
oshai-logging = "7.0.7"
@@ -38,7 +38,7 @@ slf4j = "2.0.17"
3838
spring-boot = "3.5.9"
3939
spring-management = "1.1.7"
4040
sqlite = "3.51.1.0"
41-
testcontainers = "1.19.7"
41+
testcontainers = "1.21.4"
4242

4343
[libraries]
4444
jetbrains-annotations = { module = "org.jetbrains:annotations", version.ref = "annotations" }
@@ -90,7 +90,6 @@ mcp-server = { module = "io.modelcontextprotocol:kotlin-sdk-server", version.ref
9090
slf4j-simple = { module = "org.slf4j:slf4j-simple", version.ref = "slf4j" }
9191
jetsign-gradle-plugin = { module = "com.jetbrains:jet-sign", version.ref = "jetsign" }
9292
testcontainers = { module = "org.testcontainers:testcontainers", version.ref = "testcontainers" }
93-
testcontainers-junit = { module = "org.testcontainers:junit-jupiter", version.ref = "testcontainers" }
9493
testcontainers-postgresql = { module = "org.testcontainers:postgresql", version.ref = "testcontainers" }
9594
testcontainers-mysql = { module = "org.testcontainers:mysql", version.ref = "testcontainers" }
9695
exposed-core = { module = "org.jetbrains.exposed:exposed-core", version.ref = "exposed" }

0 commit comments

Comments
 (0)