From 678884dd3f22b00ddccf35a60370f8b1631ef7dd Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 20 Jan 2026 23:46:25 +0200 Subject: [PATCH 1/5] impl: notify user when agent can't be contacted At a recent customer incident the Coder server could not ping the agent for a short brief of time because of intermittent network issues. The user had no idea they have network issues and was expecting the ssh sessions to work flawlessly. This PR lays the groundwork for monitoring the connection by checking the status for workspace, agent and agent lifecycle and make an educated guess when the network runs into issues. - resolves #246 --- .../coder/toolbox/CoderRemoteEnvironment.kt | 32 +++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 092774a..1741e15 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -9,6 +9,9 @@ import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.NetworkMetrics import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.waitForFalseWithTimeout import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action @@ -256,6 +259,9 @@ class CoderRemoteEnvironment( context.logger.info("Disconnected from $id") } + /** + * Update the workspace/agent status to the listeners, if it has changed. + */ /** * Update the workspace/agent status to the listeners, if it has changed. */ @@ -265,15 +271,41 @@ class CoderRemoteEnvironment( } this.workspace = workspace this.agent = agent + // workspace&agent status can be different from "environment status" // which is forced to queued state when a workspace is scheduled to start updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) + checkConnectionStatus(workspace, agent) // we have to regenerate the action list in order to force a redraw // because the actions don't have a state flow on the enabled property refreshAvailableActions() } + private fun checkConnectionStatus( + ws: Workspace, + agent: WorkspaceAgent + ) { + val isWorkspaceRunning = ws.latestBuild.status == WorkspaceStatus.RUNNING + val isAgentReady = agent.lifecycleState == WorkspaceAgentLifecycleState.READY + val hasConnectionIssue = agent.status in setOf( + WorkspaceAgentStatus.DISCONNECTED, + WorkspaceAgentStatus.TIMEOUT + ) + + when { + isWorkspaceRunning && isAgentReady && hasConnectionIssue -> { + context.cs.launch { + context.logAndShowWarning( + title = "Connection unstable", + warning = "Unstable connection between Coder server and workspace ${ws.name} detected. " + + "Your active sessions may disconnect. Please check the dashboard" + ) + } + } + } + } + private fun updateStatus(status: WorkspaceAndAgentStatus) { environmentStatus = status state.update { From 01374aff83c3d44febbc566dedd30779a3d41e7a Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Tue, 20 Jan 2026 23:49:09 +0200 Subject: [PATCH 2/5] chore: update user message --- src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 1741e15..4a7d4c1 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -298,8 +298,7 @@ class CoderRemoteEnvironment( context.cs.launch { context.logAndShowWarning( title = "Connection unstable", - warning = "Unstable connection between Coder server and workspace ${ws.name} detected. " + - "Your active sessions may disconnect. Please check the dashboard" + warning = "Unstable connection between Coder server and workspace ${ws.name} detected. Your active sessions may disconnect." ) } } From efc2435870974dc9ac2389162538862b0df534a8 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Jan 2026 00:35:28 +0200 Subject: [PATCH 3/5] impl: notify user only once when network errors are detected We notify the user only once, for all workspaces if there is a network disconnection detected. --- .../coder/toolbox/CoderRemoteEnvironment.kt | 27 +-- .../com/coder/toolbox/CoderToolboxContext.kt | 2 + .../coder/toolbox/CoderToolboxExtension.kt | 18 +- .../util/ConnectionMonitoringService.kt | 58 +++++++ .../resources/localization/defaultMessages.po | 6 + .../coder/toolbox/cli/CoderCLIManagerTest.kt | 5 +- .../coder/toolbox/sdk/CoderRestClientTest.kt | 5 +- .../toolbox/util/CoderProtocolHandlerTest.kt | 3 +- .../util/ConnectionMonitoringServiceTest.kt | 162 ++++++++++++++++++ 9 files changed, 253 insertions(+), 33 deletions(-) create mode 100644 src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt create mode 100644 src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt index 4a7d4c1..d143cf9 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteEnvironment.kt @@ -9,9 +9,6 @@ import com.coder.toolbox.sdk.ex.APIResponseException import com.coder.toolbox.sdk.v2.models.NetworkMetrics import com.coder.toolbox.sdk.v2.models.Workspace import com.coder.toolbox.sdk.v2.models.WorkspaceAgent -import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState -import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus -import com.coder.toolbox.sdk.v2.models.WorkspaceStatus import com.coder.toolbox.util.waitForFalseWithTimeout import com.coder.toolbox.util.withPath import com.coder.toolbox.views.Action @@ -275,35 +272,13 @@ class CoderRemoteEnvironment( // workspace&agent status can be different from "environment status" // which is forced to queued state when a workspace is scheduled to start updateStatus(WorkspaceAndAgentStatus.from(workspace, agent)) - checkConnectionStatus(workspace, agent) + context.connectionMonitoringService.checkConnectionStatus(workspace, agent) // we have to regenerate the action list in order to force a redraw // because the actions don't have a state flow on the enabled property refreshAvailableActions() } - private fun checkConnectionStatus( - ws: Workspace, - agent: WorkspaceAgent - ) { - val isWorkspaceRunning = ws.latestBuild.status == WorkspaceStatus.RUNNING - val isAgentReady = agent.lifecycleState == WorkspaceAgentLifecycleState.READY - val hasConnectionIssue = agent.status in setOf( - WorkspaceAgentStatus.DISCONNECTED, - WorkspaceAgentStatus.TIMEOUT - ) - - when { - isWorkspaceRunning && isAgentReady && hasConnectionIssue -> { - context.cs.launch { - context.logAndShowWarning( - title = "Connection unstable", - warning = "Unstable connection between Coder server and workspace ${ws.name} detected. Your active sessions may disconnect." - ) - } - } - } - } private fun updateStatus(status: WorkspaceAndAgentStatus) { environmentStatus = status diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index ac3cbcc..5308067 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -2,6 +2,7 @@ package com.coder.toolbox import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.util.ConnectionMonitoringService import com.coder.toolbox.util.toURL import com.jetbrains.toolbox.api.core.diagnostics.Logger import com.jetbrains.toolbox.api.core.os.LocalDesktopManager @@ -30,6 +31,7 @@ data class CoderToolboxContext( val settingsStore: CoderSettingsStore, val secrets: CoderSecretsStore, val proxySettings: ToolboxProxySettings, + val connectionMonitoringService: ConnectionMonitoringService, ) { /** * Try to find a URL. diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 5cfcd11..010789d 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -3,6 +3,7 @@ package com.coder.toolbox import com.coder.toolbox.settings.Environment import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore +import com.coder.toolbox.util.ConnectionMonitoringService import com.jetbrains.toolbox.api.core.PluginSecretStore import com.jetbrains.toolbox.api.core.PluginSettingsStore import com.jetbrains.toolbox.api.core.ServiceLocator @@ -26,21 +27,30 @@ import kotlinx.coroutines.CoroutineScope class CoderToolboxExtension : RemoteDevExtension { // All services must be passed in here and threaded as necessary. override fun createRemoteProviderPluginInstance(serviceLocator: ServiceLocator): RemoteProvider { + val ui = serviceLocator.getService() val logger = serviceLocator.getService(Logger::class.java) + val cs = serviceLocator.getService() + val i18n = serviceLocator.getService() return CoderRemoteProvider( CoderToolboxContext( - serviceLocator.getService(), + ui, serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), serviceLocator.getService(), - serviceLocator.getService(), + cs, serviceLocator.getService(), - serviceLocator.getService(), + i18n, CoderSettingsStore(serviceLocator.getService(), Environment(), logger), CoderSecretsStore(serviceLocator.getService()), - serviceLocator.getService() + serviceLocator.getService(), + ConnectionMonitoringService( + cs, + ui, + logger, + i18n + ) ) ) } diff --git a/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt b/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt new file mode 100644 index 0000000..0ff3e72 --- /dev/null +++ b/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt @@ -0,0 +1,58 @@ +package com.coder.toolbox.util + +import com.coder.toolbox.sdk.v2.models.Workspace +import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.ui.ToolboxUi +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import java.util.UUID + +class ConnectionMonitoringService( + private val cs: CoroutineScope, + private val ui: ToolboxUi, + private val logger: Logger, + private val i18n: LocalizableStringFactory +) { + private var alreadyNotified = false + + fun checkConnectionStatus(ws: Workspace, agent: WorkspaceAgent) { + if (alreadyNotified) { + return + } + + val isWorkspaceRunning = ws.latestBuild.status == WorkspaceStatus.RUNNING + val isAgentReady = agent.lifecycleState == WorkspaceAgentLifecycleState.READY + val hasConnectionIssue = agent.status in setOf( + WorkspaceAgentStatus.DISCONNECTED, + WorkspaceAgentStatus.TIMEOUT + ) + + when { + isWorkspaceRunning && isAgentReady && hasConnectionIssue -> { + cs.launch { + logAndShowWarning( + title = "Unstable connection detected", + warning = "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect" + ) + } + alreadyNotified = true + } + } + } + + + private suspend fun logAndShowWarning(title: String, warning: String) { + logger.warn(warning) + ui.showSnackbar( + UUID.randomUUID().toString(), + i18n.ptrl(title), + i18n.ptrl(warning), + i18n.ptrl("OK") + ) + } +} \ No newline at end of file diff --git a/src/main/resources/localization/defaultMessages.po b/src/main/resources/localization/defaultMessages.po index 16b6ed5..abff66c 100644 --- a/src/main/resources/localization/defaultMessages.po +++ b/src/main/resources/localization/defaultMessages.po @@ -191,4 +191,10 @@ msgid "Workspace name" msgstr "" msgid "Use app name as main page title instead of URL" +msgstr "" + +msgid "Unstable connection detected" +msgstr "" + +msgid "Unstable connection between Coder server and workspace detected. Your active sessions may disconnect" msgstr "" \ No newline at end of file diff --git a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt index 74caf65..15ebfcd 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -23,6 +23,7 @@ import com.coder.toolbox.store.NETWORK_INFO_DIR import com.coder.toolbox.store.SSH_CONFIG_OPTIONS import com.coder.toolbox.store.SSH_CONFIG_PATH import com.coder.toolbox.store.SSH_LOG_DIR +import com.coder.toolbox.util.ConnectionMonitoringService import com.coder.toolbox.util.InvalidVersionException import com.coder.toolbox.util.OS import com.coder.toolbox.util.SemVer @@ -100,7 +101,9 @@ internal class CoderCLIManagerTest { override fun removeProxyChangeListener(listener: Runnable) { } - }) + }, + mockk() + ) @BeforeTest fun setup() { diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index 49314c5..dc13523 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -18,6 +18,7 @@ import com.coder.toolbox.store.CoderSecretsStore import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.store.TLS_ALTERNATE_HOSTNAME import com.coder.toolbox.store.TLS_CA_PATH +import com.coder.toolbox.util.ConnectionMonitoringService import com.coder.toolbox.util.pluginTestSettingsStore import com.coder.toolbox.util.sslContextFromPEMs import com.jetbrains.toolbox.api.core.diagnostics.Logger @@ -122,7 +123,9 @@ class CoderRestClientTest { override fun removeProxyChangeListener(listener: Runnable) { } - }) + }, + mockk() + ) data class TestWorkspace(var workspace: Workspace, var resources: List? = emptyList()) diff --git a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt index 326fce0..48697eb 100644 --- a/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/CoderProtocolHandlerTest.kt @@ -50,7 +50,8 @@ internal class CoderProtocolHandlerTest { mockk(relaxed = true), CoderSettingsStore(pluginTestSettingsStore(), Environment(), mockk(relaxed = true)), mockk(), - mockk() + mockk(), + mockk() ) private val protocolHandler = CoderProtocolHandler( diff --git a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt new file mode 100644 index 0000000..14f7878 --- /dev/null +++ b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt @@ -0,0 +1,162 @@ +package com.coder.toolbox.util + +import com.coder.toolbox.sdk.v2.models.Workspace +import com.coder.toolbox.sdk.v2.models.WorkspaceAgent +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentLifecycleState +import com.coder.toolbox.sdk.v2.models.WorkspaceAgentStatus +import com.coder.toolbox.sdk.v2.models.WorkspaceBuild +import com.coder.toolbox.sdk.v2.models.WorkspaceStatus +import com.jetbrains.toolbox.api.core.diagnostics.Logger +import com.jetbrains.toolbox.api.localization.LocalizableString +import com.jetbrains.toolbox.api.localization.LocalizableStringFactory +import com.jetbrains.toolbox.api.ui.ToolboxUi +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import java.util.UUID +import kotlin.test.Test + +class ConnectionMonitoringServiceTest { + + private val ui = mockk(relaxed = true) + private val logger = mockk(relaxed = true) + private val i18n = mockk() + private val cs = TestScope(UnconfinedTestDispatcher()) + + init { + every { i18n.ptrl(any()) } answers { I18String(firstArg()) } + } + + @Test + fun `given a running workspace with a timed out agent and a ready lifecycle then expect a connection unstable notification`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) + + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a running workspace with a disconnected agent and a ready lifecycle then expect a connection unstable notification`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a stopped workspace then expect no notification`() = cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.STOPPED) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 0) { logger.warn(any()) } + coVerify(exactly = 0) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a running workspace with a disconnected agent and a starting lifecycle then expect no notification`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.STARTING) + + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 0) { logger.warn(any()) } + coVerify(exactly = 0) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a running workspace with a disconnected agent and a ready lifecycle then expect expect that user is notified only once`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + + // First call triggers notification + service.checkConnectionStatus(workspace, agent) + + // Reset mocks to verify subsequent calls + io.mockk.clearMocks(ui, logger, answers = false) + + // Second call should not trigger notification + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 0) { logger.warn(any()) } + coVerify(exactly = 0) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given a running workspace with a timed out agent and a ready lifecycle then expect expect that user is notified only once`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) + + // First call triggers notification + service.checkConnectionStatus(workspace, agent) + + // Reset mocks to verify subsequent calls + io.mockk.clearMocks(ui, logger, answers = false) + + // Second call should not trigger notification + service.checkConnectionStatus(workspace, agent) + + coVerify(exactly = 0) { logger.warn(any()) } + coVerify(exactly = 0) { ui.showSnackbar(any(), any(), any(), any()) } + } + + private fun createWorkspace(status: WorkspaceStatus): Workspace { + return Workspace( + id = UUID.randomUUID(), + templateID = UUID.randomUUID(), + templateName = "template", + templateDisplayName = "Template", + templateIcon = "icon", + latestBuild = WorkspaceBuild( + id = UUID.randomUUID(), + buildNumber = 1, + templateVersionID = UUID.randomUUID(), + resources = emptyList(), + status = status + ), + outdated = false, + name = "workspace", + ownerName = "owner" + ) + } + + private fun createAgent( + status: WorkspaceAgentStatus, + lifecycleState: WorkspaceAgentLifecycleState + ): WorkspaceAgent { + return WorkspaceAgent( + id = UUID.randomUUID(), + status = status, + name = "agent", + architecture = null, + operatingSystem = null, + directory = null, + expandedDirectory = null, + lifecycleState = lifecycleState, + loginBeforeReady = false + ) + } + + private data class I18String(val str: String) : LocalizableString +} From 8c573388a0654c7b07189c4477fbaa6d45118849 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Jan 2026 00:39:35 +0200 Subject: [PATCH 4/5] chore: more UTs to cover the connection monitoring service --- .../util/ConnectionMonitoringServiceTest.kt | 50 ++++++++++++++++--- 1 file changed, 43 insertions(+), 7 deletions(-) diff --git a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt index 14f7878..72ccb25 100644 --- a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt @@ -111,16 +111,52 @@ class ConnectionMonitoringServiceTest { // First call triggers notification service.checkConnectionStatus(workspace, agent) - // Reset mocks to verify subsequent calls - io.mockk.clearMocks(ui, logger, answers = false) - // Second call should not trigger notification service.checkConnectionStatus(workspace, agent) - coVerify(exactly = 0) { logger.warn(any()) } - coVerify(exactly = 0) { ui.showSnackbar(any(), any(), any(), any()) } + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given two running workspaces with disconnected agents and ready lifecycles then expect expect that user is notified only once`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val ws1 = createWorkspace(WorkspaceStatus.RUNNING) + val ws2 = createWorkspace(WorkspaceStatus.RUNNING) + val agent1 = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + val agent2 = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) + + // First call triggers notification + service.checkConnectionStatus(ws1, agent1) + + // Second call should not trigger notification + service.checkConnectionStatus(ws2, agent2) + + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } + } + + @Test + fun `given two running workspaces with timed out agents and ready lifecycles then expect expect that user is notified only once`() = + cs.runTest { + val service = ConnectionMonitoringService(cs, ui, logger, i18n) + val ws1 = createWorkspace(WorkspaceStatus.RUNNING) + val ws2 = createWorkspace(WorkspaceStatus.RUNNING) + val agent1 = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) + val agent2 = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) + + // First call triggers notification + service.checkConnectionStatus(ws1, agent1) + + // Second call should not trigger notification + service.checkConnectionStatus(ws2, agent2) + + coVerify(exactly = 1) { logger.warn(any()) } + coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } } + private fun createWorkspace(status: WorkspaceStatus): Workspace { return Workspace( id = UUID.randomUUID(), @@ -136,7 +172,7 @@ class ConnectionMonitoringServiceTest { status = status ), outdated = false, - name = "workspace", + name = "workspace-${UUID.randomUUID()}", ownerName = "owner" ) } @@ -148,7 +184,7 @@ class ConnectionMonitoringServiceTest { return WorkspaceAgent( id = UUID.randomUUID(), status = status, - name = "agent", + name = "agent-${UUID.randomUUID()}", architecture = null, operatingSystem = null, directory = null, From 6c6b6a9c7875fbc070523bd46eda748dbcf723e7 Mon Sep 17 00:00:00 2001 From: Faur Ioan-Aurel Date: Thu, 22 Jan 2026 00:39:49 +0200 Subject: [PATCH 5/5] chore: update gitignore file --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 01b3214..489df97 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ jvm/ # hidden macOS metadata files .DS_Store +bin/ \ No newline at end of file