diff --git a/CHANGELOG.md b/CHANGELOG.md index 5013b61..976d2e1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ ### Changed - skip the Coder TLS alternate hostname when fetching IDE metadata from JetBrains +- notifications are now persistent popups instead of snackbars, so they survive a hidden window and no longer get dropped ## 0.9.0 - 2026-05-14 diff --git a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt index debc80c..0bdc5dd 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderRemoteProvider.kt @@ -92,12 +92,6 @@ class CoderRemoteProvider( } } } - private val visibilityState = MutableStateFlow( - ProviderVisibilityState( - applicationVisible = false, - providerVisible = false - ) - ) private val linkHandler = CoderProtocolHandler(context, IdeFeedManager(context)) override val loadingEnvironmentsDescription: LocalizableString = context.i18n.ptrl("Loading workspaces...") @@ -109,8 +103,6 @@ class CoderRemoteProvider( context.envPageManager.showPluginEnvironmentsPage() } - private val errorBuffer = mutableListOf() - private val router = PageRouter() /** @@ -158,13 +150,17 @@ class CoderRemoteProvider( if (elapsed > POLL_INTERVAL * 2) { context.logger.info("wake-up from an OS sleep was detected") } else { - context.logger.error(ex, "workspace polling error encountered") if ((ex is APIResponseException && ex.isTokenExpired) || ex is OAuthTokenResponseException) { close() context.envPageManager.showPluginEnvironmentsPage() - errorBuffer.add(ex) + context.logAndShowError( + "Error encountered while setting up Coder", + "Your Coder session has expired. Please re-authenticate and try again.", + ex + ) break } + context.logger.error(ex, "workspace polling error encountered") } } @@ -331,9 +327,6 @@ class CoderRemoteProvider( * and a manual refresh button. */ override fun setVisible(visibility: ProviderVisibilityState) { - visibilityState.update { - visibility - } if (visibility.providerVisible) { context.cs.launch(CoroutineName("Notify Plugin Visibility")) { triggerProviderVisible.send(true) @@ -378,7 +371,7 @@ class CoderRemoteProvider( // showPluginEnvironmentsPage() pull it through getOverrideUiPage. val credentials = newToken?.let { Credentials.Token(it) } ?: Credentials.MTls val wizard = CoderSetupWizardPage.connectStep( - context, settingsPage, visibilityState, + context, settingsPage, url = newUrl, credentials = credentials, onConnect = onConnect.andThen(deferredLinkHandler(params, newUrl)), @@ -458,7 +451,7 @@ class CoderRemoteProvider( val oauthSessionContext = pendingOAuthConnection.session val tokenResponse = OAuth2Client(context).exchangeCode(oauthSessionContext, code) val wizard = CoderSetupWizardPage.connectStep( - context, settingsPage, visibilityState, + context, settingsPage, url = pendingOAuthConnection.url, credentials = Credentials.OAuth(oauthSessionContext.copy(tokenResponse = tokenResponse)), onConnect = onConnect, @@ -555,36 +548,30 @@ class CoderRemoteProvider( try { val url = context.deploymentUrl val credentials = autoSetupCredentials(url) ?: return CoderSetupWizardPage.deploymentUrlStep( - context, settingsPage, visibilityState, + context, settingsPage, onConnect = onConnect, onTokenRefreshed = ::onTokenRefreshed, ) return CoderSetupWizardPage.connectStep( - context, settingsPage, visibilityState, + context, settingsPage, url = url, credentials = credentials, onConnect = onConnect, onTokenRefreshed = ::onTokenRefreshed, ) } catch (ex: Exception) { - errorBuffer.add(ex) + context.logAndShowError("Error encountered while setting up Coder", "Failed to set up Coder", ex) } finally { firstRun = false } } // Login flow. - val setupWizardPage = CoderSetupWizardPage.deploymentUrlStep( - context, settingsPage, visibilityState, + return CoderSetupWizardPage.deploymentUrlStep( + context, settingsPage, onConnect = onConnect, onTokenRefreshed = ::onTokenRefreshed, ) - // We might have navigated here due to a polling error. - errorBuffer.forEach { - setupWizardPage.notify("Error encountered", it) - } - errorBuffer.clear() - return setupWizardPage } /** diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt index 1b14622..c371c33 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxContext.kt @@ -18,7 +18,6 @@ import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.net.URL -import java.util.UUID @Suppress("UnstableApiUsage") data class CoderToolboxContext( @@ -34,8 +33,9 @@ data class CoderToolboxContext( val settingsStore: CoderSettingsStore, val secrets: CoderSecretsStore, val proxySettings: ToolboxProxySettings, - val connectionMonitoringService: ConnectionMonitoringService, ) { + val connectionMonitoringService: ConnectionMonitoringService = ConnectionMonitoringService(this) + /** * Try to find a URL. * @@ -54,49 +54,50 @@ data class CoderToolboxContext( fun logAndShowError(title: String, error: String) { logger.error(error) - showSnackbar(title, error) + showInfoPopup(title, error) } fun logAndShowError(title: String, error: String, exception: Exception) { logger.error(exception, error) - showSnackbar(title, error) + showInfoPopup(title, error) } fun logAndShowWarning(title: String, warning: String) { logger.warn(warning) - showSnackbar(title, warning) + showInfoPopup(title, warning) } fun logAndShowInfo(title: String, info: String) { logger.info(info) - showSnackbar(title, info) + showInfoPopup(title, info) } /** - * Displays a snackbar on a child of the plugin coroutine scope rather than on the - * caller's coroutine, without waiting for it. + * Displays an informational popup on a child of the plugin coroutine scope rather than on + * the caller's coroutine, without waiting for it. + * + * Unlike [ToolboxUi.showSnackbar], a popup is backed by a persistent dialog state: it is + * still rendered once the window becomes visible even if it was requested while the window + * was hidden, it is not silently dropped when several are requested, and dismissing it + * resumes the [ToolboxUi.showInfoPopup] coroutine normally instead of cancelling it. * - * Toolbox keeps the [ToolboxUi.showSnackbar] coroutine suspended for the entire lifetime - * of the snackbar and cancels it (rather than resuming it) when the snackbar goes away. - * Calling it directly on the caller's coroutine would therefore either block the caller - * until the snackbar is gone or, on dismissal, abruptly cancel the caller (e.g. the URI - * handler) - skipping any code that runs after the error is shown, such as resetting the - * busy state. Launching it fire-and-forget on the plugin scope lets the caller continue - * immediately while the snackbar lives independently. + * It is launched fire-and-forget so the caller is not suspended until the user closes the + * popup - the caller (e.g. the URI handler) can run any follow-up code, such as resetting + * the busy state, immediately. The popups are serialized via [popupMutex] so they are + * shown one after another rather than overwriting each other. */ - fun showSnackbar(title: String, text: String) { - cs.launch(CoroutineName("snackbar")) { + fun showInfoPopup(title: String, text: String) { + cs.launch(CoroutineName("popup")) { try { - ui.showSnackbar( - UUID.randomUUID().toString(), + ui.showInfoPopup( i18n.pnotr(title), i18n.pnotr(text), i18n.ptrl("OK") ) } catch (_: CancellationException) { - // Expected when the snackbar is dismissed or the plugin scope shuts down. + // Expected when the plugin scope shuts down while the popup is open. } catch (ex: Exception) { - logger.error(ex, "Failed to display snackbar with title '$title'") + logger.error(ex, "Failed to display popup with title '$title'") } } } diff --git a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt index 010789d..4932ad8 100644 --- a/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt +++ b/src/main/kotlin/com/coder/toolbox/CoderToolboxExtension.kt @@ -3,7 +3,6 @@ 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 @@ -45,12 +44,6 @@ class CoderToolboxExtension : RemoteDevExtension { CoderSettingsStore(serviceLocator.getService(), Environment(), logger), CoderSecretsStore(serviceLocator.getService()), serviceLocator.getService(), - ConnectionMonitoringService( - cs, - ui, - logger, - i18n - ) ) ) } diff --git a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt index c6a2d94..5e09b38 100644 --- a/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt +++ b/src/main/kotlin/com/coder/toolbox/util/CoderProtocolHandler.kt @@ -17,7 +17,6 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.time.withTimeout import java.net.URL -import java.util.UUID import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes import kotlin.time.Duration.Companion.seconds @@ -273,8 +272,7 @@ open class CoderProtocolHandler( context.logger.info("Successfully installed $selectedIde on $environmentId.") return selectedIde } else { - context.ui.showSnackbar( - UUID.randomUUID().toString(), + context.ui.showInfoPopup( context.i18n.pnotr("$selectedIde could not be installed"), context.i18n.pnotr("$selectedIde could not be installed on time. Check the logs for more details"), context.i18n.ptrl("OK") diff --git a/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt b/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt index 0ff3e72..dd24342 100644 --- a/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt +++ b/src/main/kotlin/com/coder/toolbox/util/ConnectionMonitoringService.kt @@ -1,22 +1,14 @@ package com.coder.toolbox.util +import com.coder.toolbox.CoderToolboxContext 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 val context: CoderToolboxContext ) { private var alreadyNotified = false @@ -34,25 +26,12 @@ class ConnectionMonitoringService( 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" - ) - } + context.logAndShowWarning( + "Unstable connection detected", + "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/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt b/src/main/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt index 563a0e23..2481bd8 100644 --- a/src/main/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt +++ b/src/main/kotlin/com/coder/toolbox/views/CoderSetupWizardPage.kt @@ -8,12 +8,10 @@ import com.coder.toolbox.views.state.Credentials import com.coder.toolbox.views.state.PendingOAuthConnection import com.coder.toolbox.views.state.WizardModel import com.coder.toolbox.views.state.WizardStep -import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.actions.RunnableActionDescription import com.jetbrains.toolbox.api.ui.components.UiField import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import java.net.URL @@ -21,7 +19,6 @@ import java.net.URL class CoderSetupWizardPage private constructor( private val context: CoderToolboxContext, private val settingsPage: CoderSettingsPage, - visibilityState: StateFlow, private var autoLogin: Boolean = false, onConnect: SuspendBiConsumer, onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null @@ -31,17 +28,15 @@ class CoderSetupWizardPage private constructor( context.ui.showUiPage(settingsPage) } - private val deploymentUrlStep = DeploymentUrlStep(context, model, visibilityState) + private val deploymentUrlStep = DeploymentUrlStep(context, model) private val tokenStep = TokenStep(context, model) private val connectStep = ConnectStep( context, model, - visibilityState, navigateBack = this::navigateBackFromConnect, onConnect = onConnect, onTokenRefreshed = onTokenRefreshed ) - private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) private var stateCollectJob: Job? = null /** @@ -58,7 +53,6 @@ class CoderSetupWizardPage private constructor( displaySteps() } } - errorReporter.flush() } private fun displaySteps() { @@ -153,21 +147,14 @@ class CoderSetupWizardPage private constructor( stateCollectJob?.cancel() } - /** - * Show an error as a popup on this page. - */ - fun notify(message: String, ex: Throwable) = errorReporter.report(message, ex) - - companion object { fun deploymentUrlStep( context: CoderToolboxContext, settingsPage: CoderSettingsPage, - visibilityState: StateFlow, onConnect: SuspendBiConsumer, onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null, ): CoderSetupWizardPage = CoderSetupWizardPage( - context, settingsPage, visibilityState, + context, settingsPage, onConnect = onConnect, onTokenRefreshed = onTokenRefreshed, ).apply { model.goToFirst() } @@ -175,13 +162,12 @@ class CoderSetupWizardPage private constructor( fun connectStep( context: CoderToolboxContext, settingsPage: CoderSettingsPage, - visibilityState: StateFlow, url: URL, credentials: Credentials, onConnect: SuspendBiConsumer, onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null, ): CoderSetupWizardPage = CoderSetupWizardPage( - context, settingsPage, visibilityState, + context, settingsPage, autoLogin = true, onConnect = onConnect, onTokenRefreshed = onTokenRefreshed, diff --git a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt index 03e4437..bea6ef8 100644 --- a/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/ConnectStep.kt @@ -8,7 +8,6 @@ import com.coder.toolbox.plugin.PluginManager import com.coder.toolbox.sdk.CoderRestClient import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.coder.toolbox.views.state.WizardModel -import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.ValidationErrorField @@ -16,7 +15,6 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.yield @@ -31,7 +29,6 @@ private const val WIZARD_WAS_DISPOSED = "Wizard was disposed" class ConnectStep( private val context: CoderToolboxContext, private val model: WizardModel, - visibilityState: StateFlow, private val navigateBack: () -> Unit, private val onConnect: SuspendBiConsumer, private val onTokenRefreshed: (suspend (url: URL, oauthSessionCtx: CoderOAuthSessionContext) -> Unit)? = null @@ -40,7 +37,6 @@ class ConnectStep( private val statusField = LabelField(context.i18n.pnotr("")) private val errorField = ValidationErrorField(context.i18n.pnotr("")) - private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) override val panel: RowGroup = RowGroup( RowGroup.RowField(statusField), @@ -48,7 +44,6 @@ class ConnectStep( ) override fun onVisible() { - errorReporter.flush() errorField.textState.update { context.i18n.pnotr("") } @@ -149,11 +144,11 @@ class ConnectStep( // dispose() must cancel without navigating. Treat these control-flow // cancellations separately so we do not run navigateBack() twice. if (ex.message != USER_HIT_THE_BACK_BUTTON && ex.message != WIZARD_WAS_DISPOSED) { - errorReporter.report("Failed to configure $hostName", ex) + context.logAndShowError("Error encountered while setting up Coder", "Failed to configure $hostName", ex) navigateBack() } } catch (ex: Exception) { - errorReporter.report("Failed to configure $hostName", ex) + context.logAndShowError("Error encountered while setting up Coder", "Failed to configure $hostName", ex) navigateBack() } } diff --git a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt index 8efd7d5..71ce31e 100644 --- a/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt +++ b/src/main/kotlin/com/coder/toolbox/views/DeploymentUrlStep.kt @@ -12,7 +12,6 @@ import com.coder.toolbox.util.toURL import com.coder.toolbox.util.validateStrictWebUrl import com.coder.toolbox.views.state.CoderOAuthSessionContext import com.coder.toolbox.views.state.WizardModel -import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState import com.jetbrains.toolbox.api.ui.components.CheckboxField import com.jetbrains.toolbox.api.ui.components.LabelField import com.jetbrains.toolbox.api.ui.components.LabelStyleType @@ -20,7 +19,6 @@ import com.jetbrains.toolbox.api.ui.components.RowGroup import com.jetbrains.toolbox.api.ui.components.TextField import com.jetbrains.toolbox.api.ui.components.TextType import com.jetbrains.toolbox.api.ui.components.ValidationErrorField -import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.update import okhttp3.HttpUrl.Companion.toHttpUrl import java.net.MalformedURLException @@ -40,11 +38,8 @@ private const val OAUTH2_SCOPE: String = class DeploymentUrlStep( private val context: CoderToolboxContext, private val model: WizardModel, - visibilityState: StateFlow, ) : WizardStep { - private val errorReporter = ErrorReporter.create(context, visibilityState, this.javaClass) - private val urlField = TextField(context.i18n.ptrl("Deployment URL"), "", TextType.General) private val emptyLine = LabelField(context.i18n.pnotr(""), LabelStyleType.Normal) @@ -82,7 +77,6 @@ class DeploymentUrlStep( signatureFallbackStrategyField.checkedState.update { context.settingsStore.fallbackOnCoderForSignatures.isAllowed() } - errorReporter.flush() } override suspend fun onNext(): Boolean { @@ -96,7 +90,7 @@ class DeploymentUrlStep( try { model.url = validateRawUrl(rawUrl) } catch (e: MalformedURLException) { - errorReporter.report("URL is invalid", e) + context.logAndShowError("Error encountered while setting up Coder", "URL is invalid", e) return false } @@ -110,7 +104,11 @@ class DeploymentUrlStep( model.oauthSession = handleOAuth2(rawUrl) return false } catch (e: Exception) { - errorReporter.report("Failed to authenticate with OAuth2: ${e.message}", e) + context.logAndShowError( + "Error encountered while setting up Coder", + "Failed to authenticate with OAuth2: ${e.message}", + e + ) return false } } diff --git a/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt b/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt deleted file mode 100644 index 1549bfd..0000000 --- a/src/main/kotlin/com/coder/toolbox/views/ErrorReporter.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.coder.toolbox.views - -import com.coder.toolbox.CoderToolboxContext -import com.coder.toolbox.util.prettify -import com.jetbrains.toolbox.api.remoteDev.ProviderVisibilityState -import kotlinx.coroutines.flow.StateFlow - -sealed class ErrorReporter { - - /** - * Logs and show errors as popups. - */ - abstract fun report(message: String, ex: Throwable) - - /** - * Processes any buffered errors when the application becomes visible. - */ - abstract fun flush() - - companion object { - fun create( - context: CoderToolboxContext, - visibilityState: StateFlow, - callerClass: Class<*> - ): ErrorReporter = ErrorReporterImpl(context, visibilityState, callerClass) - } -} - -private class ErrorReporterImpl( - private val context: CoderToolboxContext, - private val visibilityState: StateFlow, - private val callerClass: Class<*> -) : ErrorReporter() { - private val errorBuffer = mutableListOf() - - override fun report(message: String, ex: Throwable) { - context.logger.error(ex, "[${callerClass.simpleName}] $message") - if (!visibilityState.value.applicationVisible) { - context.logger.debug("Toolbox is not yet visible, scheduling error to be displayed later") - errorBuffer.add(ex) - return - } - showError(ex) - } - - private fun showError(ex: Throwable) { - val textError = ex.prettify() - context.showSnackbar("Error encountered while setting up Coder", textError) - } - - override fun flush() { - if (errorBuffer.isNotEmpty() && visibilityState.value.applicationVisible) { - errorBuffer.forEach { - showError(it) - } - errorBuffer.clear() - } - } -} \ 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 bcd321f..3ce1b2d 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/CoderCLIManagerTest.kt @@ -22,7 +22,6 @@ 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 @@ -101,7 +100,6 @@ internal class CoderCLIManagerTest { override fun removeProxyChangeListener(listener: Runnable) { } }, - mockk() ) @BeforeTest diff --git a/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt b/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt index 2876fbe..3f818c1 100644 --- a/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt +++ b/src/test/kotlin/com/coder/toolbox/cli/EnsureCLITest.kt @@ -9,7 +9,6 @@ import com.coder.toolbox.store.CoderSettingsStore import com.coder.toolbox.store.DATA_DIRECTORY import com.coder.toolbox.store.DISABLE_SIGNATURE_VALIDATION import com.coder.toolbox.store.ENABLE_DOWNLOADS -import com.coder.toolbox.util.ConnectionMonitoringService import com.coder.toolbox.util.IgnoreOnWindows import com.coder.toolbox.util.OS import com.coder.toolbox.util.SemVer @@ -79,7 +78,6 @@ internal class EnsureCLITest { override fun addProxyChangeListener(listener: Runnable) {} override fun removeProxyChangeListener(listener: Runnable) {} }, - mockk(), ) @BeforeTest diff --git a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt index a91e7ba..6640807 100644 --- a/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt +++ b/src/test/kotlin/com/coder/toolbox/sdk/CoderRestClientTest.kt @@ -18,7 +18,6 @@ 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 @@ -124,7 +123,6 @@ class CoderRestClientTest { override fun removeProxyChangeListener(listener: Runnable) { } }, - mockk() ) diff --git a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt index 020eec0..832ef60 100644 --- a/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt +++ b/src/test/kotlin/com/coder/toolbox/util/ConnectionMonitoringServiceTest.kt @@ -1,160 +1,132 @@ package com.coder.toolbox.util +import com.coder.toolbox.CoderToolboxContext 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.clearMocks import io.mockk.mockk -import kotlinx.coroutines.test.TestScope -import kotlinx.coroutines.test.UnconfinedTestDispatcher -import kotlinx.coroutines.test.runTest +import io.mockk.verify 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()) } - } + private val context = mockk(relaxed = true) @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) + fun `given a running workspace with a timed out agent and a ready lifecycle then expect a connection unstable notification`() { + val service = ConnectionMonitoringService(context) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) - service.checkConnectionStatus(workspace, agent) + service.checkConnectionStatus(workspace, agent) - coVerify(exactly = 1) { logger.warn(any()) } - coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } - } + verify(exactly = 1) { context.logAndShowWarning(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) + fun `given a running workspace with a disconnected agent and a ready lifecycle then expect a connection unstable notification`() { + val service = ConnectionMonitoringService(context) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) - service.checkConnectionStatus(workspace, agent) + service.checkConnectionStatus(workspace, agent) - coVerify(exactly = 1) { logger.warn(any()) } - coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } - } + verify(exactly = 1) { context.logAndShowWarning(any(), any()) } + } @Test - fun `given a stopped workspace then expect no notification`() = cs.runTest { - val service = ConnectionMonitoringService(cs, ui, logger, i18n) + fun `given a stopped workspace then expect no notification`() { + val service = ConnectionMonitoringService(context) 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()) } + verify(exactly = 0) { context.logAndShowWarning(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) + fun `given a running workspace with a disconnected agent and a starting lifecycle then expect no notification`() { + val service = ConnectionMonitoringService(context) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.STARTING) - service.checkConnectionStatus(workspace, agent) + service.checkConnectionStatus(workspace, agent) - coVerify(exactly = 0) { logger.warn(any()) } - coVerify(exactly = 0) { ui.showSnackbar(any(), any(), any(), any()) } - } + verify(exactly = 0) { context.logAndShowWarning(any(), any()) } + } @Test - fun `given a running workspace with a disconnected agent and a ready lifecycle then 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) + fun `given a running workspace with a disconnected agent and a ready lifecycle then expect that user is notified only once`() { + val service = ConnectionMonitoringService(context) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.DISCONNECTED, WorkspaceAgentLifecycleState.READY) - // First call triggers notification - service.checkConnectionStatus(workspace, agent) + // First call triggers notification + service.checkConnectionStatus(workspace, agent) - // Reset mocks to verify subsequent calls - io.mockk.clearMocks(ui, logger, answers = false) + // Reset mocks to verify subsequent calls + clearMocks(context, answers = false) - // Second call should not trigger notification - service.checkConnectionStatus(workspace, agent) + // 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()) } - } + verify(exactly = 0) { context.logAndShowWarning(any(), any()) } + } @Test - fun `given a running workspace with a timed out agent and a ready lifecycle then 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) + fun `given a running workspace with a timed out agent and a ready lifecycle then expect that user is notified only once`() { + val service = ConnectionMonitoringService(context) + val workspace = createWorkspace(WorkspaceStatus.RUNNING) + val agent = createAgent(WorkspaceAgentStatus.TIMEOUT, WorkspaceAgentLifecycleState.READY) - // First call triggers notification - service.checkConnectionStatus(workspace, agent) + // First call triggers notification + service.checkConnectionStatus(workspace, agent) - // Second call should not trigger notification - service.checkConnectionStatus(workspace, agent) + // Second call should not trigger notification + service.checkConnectionStatus(workspace, agent) - coVerify(exactly = 1) { logger.warn(any()) } - coVerify(exactly = 1) { ui.showSnackbar(any(), any(), any(), any()) } - } + verify(exactly = 1) { context.logAndShowWarning(any(), any()) } + } @Test - fun `given two running workspaces with disconnected agents and ready lifecycles then 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) + fun `given two running workspaces with disconnected agents and ready lifecycles then expect that user is notified only once`() { + val service = ConnectionMonitoringService(context) + 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) + // First call triggers notification + service.checkConnectionStatus(ws1, agent1) - // Second call should not trigger notification - service.checkConnectionStatus(ws2, agent2) + // 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()) } - } + verify(exactly = 1) { context.logAndShowWarning(any(), any()) } + } @Test - fun `given two running workspaces with timed out agents and ready lifecycles then 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) + fun `given two running workspaces with timed out agents and ready lifecycles then expect that user is notified only once`() { + val service = ConnectionMonitoringService(context) + 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) + // First call triggers notification + service.checkConnectionStatus(ws1, agent1) - // Second call should not trigger notification - service.checkConnectionStatus(ws2, agent2) + // 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()) } - } + verify(exactly = 1) { context.logAndShowWarning(any(), any()) } + } private fun createWorkspace(status: WorkspaceStatus): Workspace { @@ -193,6 +165,4 @@ class ConnectionMonitoringServiceTest { loginBeforeReady = false ) } - - private data class I18String(val str: String) : LocalizableString }