Skip to content

Commit 4a29fb1

Browse files
committed
added unit tests for DevSpacesConnection connect/teardown logic (crw-11670)
- waitForThinClientConnect: happy path, timeout, transient flap, connectFailed - onThinClientClosed: teardown when live vs no-op when not live - Expose CONNECT_TIMEOUT and timeoutMs param for test injection
1 parent 3e7a220 commit 4a29fb1

2 files changed

Lines changed: 173 additions & 4 deletions

File tree

src/main/kotlin/com/redhat/devtools/gateway/DevSpacesConnection.kt

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ import java.util.concurrent.atomic.AtomicBoolean
5656
class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
5757

5858
companion object {
59+
const val CONNECT_TIMEOUT: Long = 60 * 1000 // millis
5960
private const val CONNECT_POLL_MS = 200L
6061
}
6162

@@ -134,7 +135,7 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
134135
* already live; failures during connect are cleaned up by [connect]'s catch.
135136
*/
136137
@Suppress("UnstableApiUsage")
137-
private fun onThinClientClosed(
138+
internal fun onThinClientClosed(
138139
connectFailed: AtomicBoolean,
139140
connectionLive: AtomicBoolean,
140141
thinClient: ThinClientHandle,
@@ -372,13 +373,14 @@ class DevSpacesConnection(private val devSpacesContext: DevSpacesContext) {
372373
}
373374

374375
@Suppress("UnstableApiUsage")
375-
private suspend fun waitForThinClientConnect(
376+
internal suspend fun waitForThinClientConnect(
376377
thinClient: ThinClientHandle,
377378
connectFailed: AtomicBoolean,
378-
checkCancelled: (() -> Unit)?
379+
checkCancelled: (() -> Unit)?,
380+
timeoutMs: Long = CONNECT_TIMEOUT
379381
) {
380382
@Suppress("ConvertLongToDuration")
381-
val connected = withTimeoutOrNull(60_000L) {
383+
val connected = withTimeoutOrNull(timeoutMs) {
382384
// Keep polling while the client is not present and no failure was reported:
383385
// a transient absence must not fail the wait
384386
while (!thinClient.clientPresent && !connectFailed.get()) {
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
/*
2+
* Copyright (c) 2026 Red Hat, Inc.
3+
* This program and the accompanying materials are made
4+
* available under the terms of the Eclipse Public License 2.0
5+
* which is available at https://www.eclipse.org/legal/epl-2.0/
6+
*
7+
* SPDX-License-Identifier: EPL-2.0
8+
*
9+
* Contributors:
10+
* Red Hat, Inc. - initial API and implementation
11+
*/
12+
package com.redhat.devtools.gateway.devworkspace
13+
14+
import com.jetbrains.gateway.thinClientLink.ThinClientHandle
15+
import com.redhat.devtools.gateway.DevSpacesConnection
16+
import com.redhat.devtools.gateway.DevSpacesContext
17+
import com.redhat.devtools.gateway.server.RemoteIDEServer
18+
import io.mockk.*
19+
import kotlinx.coroutines.test.runTest
20+
import org.assertj.core.api.Assertions.assertThat
21+
import org.junit.jupiter.api.BeforeEach
22+
import org.junit.jupiter.api.Test
23+
import java.util.concurrent.atomic.AtomicBoolean
24+
25+
class DevSpacesConnectionTest {
26+
27+
private lateinit var devSpacesContext: DevSpacesContext
28+
private lateinit var thinClient: ThinClientHandle
29+
30+
private val namespace = "test-namespace"
31+
private val workspaceName = "test-workspace"
32+
33+
private lateinit var connection: DevSpacesConnection
34+
35+
@BeforeEach
36+
fun beforeEach() {
37+
devSpacesContext = mockk(relaxed = true) {
38+
every { devWorkspace.namespace } returns namespace
39+
every { devWorkspace.name } returns workspaceName
40+
}
41+
thinClient = mockk(relaxed = true) {
42+
every { clientPresent } returns false
43+
every { lifetime } returns mockk(relaxed = true)
44+
}
45+
46+
connection = DevSpacesConnection(devSpacesContext)
47+
48+
// Mock DevWorkspaces.get() for tearDownConnection's DevWorkspacePatch
49+
mockkConstructor(DevWorkspaces::class)
50+
every { anyConstructed<DevWorkspaces>().get(any<String>(), any<String>()) } returns mockk(relaxed = true) {
51+
every { annotations } returns emptyMap()
52+
}
53+
}
54+
55+
// -- waitForThinClientConnect tests (covers: DevSpacesConnection, startThinClient, waitForThinClientConnect) --
56+
57+
@Test
58+
fun `waitForThinClientConnect succeeds when client is present`() = runTest {
59+
// given
60+
val connectFailed = AtomicBoolean(false)
61+
every { thinClient.clientPresent } returns true
62+
63+
// when
64+
connection.waitForThinClientConnect(thinClient, connectFailed, null)
65+
66+
// then — no exception means success
67+
}
68+
69+
@Test
70+
fun `waitForThinClientConnect times out when client is never present`() = runTest {
71+
// given
72+
val connectFailed = AtomicBoolean(false)
73+
every { thinClient.clientPresent } returns false
74+
75+
// when/then — short timeout so the test completes quickly
76+
var thrown: Throwable? = null
77+
try {
78+
connection.waitForThinClientConnect(thinClient, connectFailed, null, timeoutMs = 500L)
79+
} catch (e: Throwable) {
80+
thrown = e
81+
}
82+
assertThat(thrown).isInstanceOf(IllegalStateException::class.java)
83+
assertThat(thrown?.message).contains("Could not connect")
84+
}
85+
86+
@Test
87+
fun `waitForThinClientConnect tolerates transient presence absence`() = runTest {
88+
// given — simulate transient absence: absent for first N polls, then present
89+
val connectFailed = AtomicBoolean(false)
90+
var callCount = 0
91+
every { thinClient.clientPresent } answers {
92+
callCount++
93+
callCount > 3
94+
}
95+
96+
// when — generous timeout so the loop can recover
97+
connection.waitForThinClientConnect(thinClient, connectFailed, null, timeoutMs = 10_000L)
98+
99+
// then — loop polled at least 4 times (3 absent + 1 present)
100+
assertThat(callCount).isGreaterThan(3)
101+
}
102+
103+
@Test
104+
fun `waitForThinClientConnect fails when connectFailed is set`() = runTest {
105+
// given — connectFailed already set before calling waitForThinClientConnect
106+
val connectFailed = AtomicBoolean(true)
107+
every { thinClient.clientPresent } returns false
108+
109+
// when/then
110+
var thrown: Throwable? = null
111+
try {
112+
connection.waitForThinClientConnect(thinClient, connectFailed, null, timeoutMs = 1_000L)
113+
} catch (e: Throwable) {
114+
thrown = e
115+
}
116+
assertThat(thrown).isInstanceOf(IllegalStateException::class.java)
117+
assertThat(thrown?.message).contains("Could not connect")
118+
}
119+
120+
// -- onThinClientClosed tests (covers: onThinClientClosed) --
121+
122+
@Test
123+
fun `onThinClientClosed sets connectFailed and tears down when live`() {
124+
// given
125+
val connectFailed = AtomicBoolean(false)
126+
val connectionLive = AtomicBoolean(true)
127+
128+
// when
129+
connection.onThinClientClosed(
130+
connectFailed,
131+
connectionLive,
132+
thinClient,
133+
devSpacesContext.devWorkspace,
134+
mockk<() -> Unit>(relaxed = true),
135+
{},
136+
null,
137+
null
138+
)
139+
140+
// then
141+
assertThat(connectFailed.get()).isTrue()
142+
verify { devSpacesContext.removeWorkspace(devSpacesContext.devWorkspace) }
143+
}
144+
145+
@Test
146+
fun `onThinClientClosed does not tear down when not live`() {
147+
// given
148+
val connectFailed = AtomicBoolean(false)
149+
val connectionLive = AtomicBoolean(false)
150+
151+
// when
152+
connection.onThinClientClosed(
153+
connectFailed,
154+
connectionLive,
155+
thinClient,
156+
devSpacesContext.devWorkspace,
157+
mockk<() -> Unit>(relaxed = true),
158+
{},
159+
null,
160+
null
161+
)
162+
163+
// then
164+
assertThat(connectFailed.get()).isTrue()
165+
verify(exactly = 0) { devSpacesContext.removeWorkspace(any()) }
166+
}
167+
}

0 commit comments

Comments
 (0)