From 33d87a11ec108af4a9a5f69c8dc0b18d9d1b42a5 Mon Sep 17 00:00:00 2001 From: m57 <41645758+Hy4ri@users.noreply.github.com> Date: Wed, 23 Sep 2026 12:25:21 +0300 Subject: [PATCH] fix(#1230,#1221): clarify inactive plugins and verify uninstall (#1236) --- .../ui/plugins/PluginCardTest.kt | 59 ++++++ .../hermescontrol/data/model/PluginInfo.kt | 3 + .../data/remote/HermesApiService.kt | 5 - .../data/ws/PluginRemovalRepository.kt | 31 +++ .../m57/hermescontrol/data/ws/WsMethods.kt | 4 + .../hermescontrol/ui/plugins/PluginsScreen.kt | 112 ++++++----- .../ui/plugins/PluginsViewModel.kt | 124 +++++++++--- app/src/main/res/values-ar/strings.xml | 1 + app/src/main/res/values-ko/strings.xml | 1 + app/src/main/res/values-zh/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + .../data/ws/PluginRemovalRepositoryTest.kt | 48 +++++ .../ui/plugins/PluginsViewModelTest.kt | 179 ++++++++++++++++++ 13 files changed, 475 insertions(+), 94 deletions(-) create mode 100644 app/src/androidTest/java/com/m57/hermescontrol/ui/plugins/PluginCardTest.kt create mode 100644 app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/plugins/PluginCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/plugins/PluginCardTest.kt new file mode 100644 index 00000000..f9028ba7 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/plugins/PluginCardTest.kt @@ -0,0 +1,59 @@ +package com.m57.hermescontrol.ui.plugins + +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.filters.MediumTest +import com.m57.hermescontrol.data.model.PluginInfo +import com.m57.hermescontrol.theme.HermesControlTheme +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@MediumTest +class PluginCardTest { + @get:Rule val composeRule = createComposeRule() + + @Test + fun inactiveUserPluginOffersEnableAndUninstallInsteadOfInstall() { + val viewModel = PluginsViewModel() + composeRule.setContent { + HermesControlTheme { + PluginCard( + plugin = PluginInfo(name = "demo", source = "user", runtimeStatus = "inactive", canRemove = true), + state = PluginsUiState(), + viewModel = viewModel, + onClick = {}, + ) + } + } + + composeRule.onNodeWithText("Install", useUnmergedTree = true).assertDoesNotExist() + composeRule.onNodeWithText("Enable", useUnmergedTree = true).assertExists() + composeRule.onNodeWithText("Uninstall", useUnmergedTree = true).assertExists() + } + + @Test + fun bundledPluginNeverShowsUninstall() { + val viewModel = PluginsViewModel() + composeRule.setContent { + HermesControlTheme { + PluginCard( + plugin = + PluginInfo( + name = "builtin", + source = "bundled", + runtimeStatus = "inactive", + canRemove = true, + ), + state = PluginsUiState(), + viewModel = viewModel, + onClick = {}, + ) + } + } + + composeRule.onNodeWithText("Uninstall", useUnmergedTree = true).assertDoesNotExist() + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt index 5ba18ac5..1e00957e 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/PluginInfo.kt @@ -24,6 +24,9 @@ data class PluginInfo( get() = runtimeStatus.equals("enabled", ignoreCase = true) || runtimeStatus.equals("disabled", ignoreCase = true) + + val removable: Boolean + get() = canRemove && (source == "user" || source == "git") } @Serializable diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt index 7b0a393b..bbaf3811 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/HermesApiService.kt @@ -665,11 +665,6 @@ interface HermesApiService : KanbanApiService { @Body body: AgentPluginInstallBody, ): Response - @DELETE("api/dashboard/agent-plugins/{name}") - suspend fun uninstallPlugin( - @Path("name", encoded = true) name: String, - ): Response - @POST("api/dashboard/agent-plugins/{name}/update") suspend fun updatePlugin( @Path("name", encoded = true) name: String, diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt new file mode 100644 index 00000000..92766afe --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/PluginRemovalRepository.kt @@ -0,0 +1,31 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.coroutines.CancellationException +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.decodeFromJsonElement + +@Serializable +data class PluginRemovalResult( + val ok: Boolean, + val name: String? = null, + val error: String? = null, +) + +/** Removes a user-installed plugin through the gateway's canonical plugin manager. */ +class PluginRemovalRepository( + private val rpcRequest: suspend (String, Map) -> Any? = { method, params -> + val deferred = HermesWsClient.request(method, params) + try { + deferred.await() + } catch (e: CancellationException) { + deferred.cancel(e) + throw e + } + }, +) { + suspend fun remove(name: String): PluginRemovalResult { + val result = rpcRequest(WsMethods.PLUGINS_MANAGE, mapOf("action" to "remove", "name" to name)) + return Json.decodeFromJsonElement(result.toJsonElement()) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 93e3c448..f4e79a7f 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -110,6 +110,9 @@ object WsMethods { /** Initiate or restart authorization flow for one or more connectors. */ const val CONNECTORS_CONNECT = "connectors.connect" + // ── Plugins ─────────────────────────────────────────────────────────── + const val PLUGINS_MANAGE = "plugins.manage" + // ── Billing / subscription (issue #628) ───────────────────────────── // Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e). // `credits.view` was REMOVED upstream; these replace it. `usage.bars` @@ -174,6 +177,7 @@ object WsMethods { CONFIG_SET, // methods_config_set.py: @method("config.set") + @_profile_scoped "verification.status", // line 282 PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped + PLUGINS_MANAGE, // methods_tools.py: @_scoped_rpc "pet.info", // line 1253 "pet.info.meta", // line 1279 "pet.cells", // line 1302 diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 9d2bca7b..5cbd407a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -486,7 +486,7 @@ private fun InstallSection( } @Composable -private fun PluginCard( +internal fun PluginCard( plugin: PluginInfo, state: PluginsUiState, viewModel: PluginsViewModel, @@ -542,12 +542,7 @@ private fun PluginCard( // Status badges Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - val statusColor = - when { - plugin.enabled -> statusColors.success - plugin.installed -> statusColors.warning - else -> statusColors.error - } + val statusColor = if (plugin.enabled) statusColors.success else statusColors.warning Text( text = plugin.runtimeStatus ?: "inactive", style = MaterialTheme.typography.labelSmall, @@ -591,64 +586,63 @@ private fun PluginCard( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End, ) { - if (plugin.installed) { - // Visibility toggle (only for plugins with dashboard manifest) - if (plugin.hasDashboardManifest) { - IconButton( - onClick = { viewModel.togglePluginVisibility(plugin) }, - enabled = !busy, - modifier = Modifier.size(32.dp), - ) { - Icon( - if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility, - contentDescription = - if (plugin.userHidden) { - stringResource(R.string.plugins_show_in_sidebar) - } else { - stringResource(R.string.plugins_hide_from_sidebar) - }, - modifier = Modifier.size(18.dp), - ) - } + // Visibility toggle (only for active plugins with dashboard manifests) + if (plugin.installed && plugin.hasDashboardManifest) { + IconButton( + onClick = { viewModel.togglePluginVisibility(plugin) }, + enabled = !busy, + modifier = Modifier.size(32.dp), + ) { + Icon( + if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility, + contentDescription = + if (plugin.userHidden) { + stringResource(R.string.plugins_show_in_sidebar) + } else { + stringResource(R.string.plugins_hide_from_sidebar) + }, + modifier = Modifier.size(18.dp), + ) + } + Spacer(modifier = Modifier.width(4.dp)) + } + + // Existing update affordance for active Git installs. + if (plugin.installed && plugin.canUpdateGit) { + OutlinedButton( + onClick = { viewModel.updatePlugin(plugin.name) }, + enabled = !busy, + ) { + Text(stringResource(R.string.plugins_action_update)) + } + Spacer(modifier = Modifier.width(8.dp)) + } + + // Backend-provided removability, independent of runtime enabled state. + if (plugin.removable) { + OutlinedButton( + onClick = { viewModel.requestRemovePlugin(plugin.name) }, + enabled = !busy, + colors = + ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error, + ), + ) { + Icon( + Icons.Default.Delete, + contentDescription = null, + modifier = Modifier.size(16.dp), + ) Spacer(modifier = Modifier.width(4.dp)) + Text(stringResource(R.string.plugins_action_uninstall)) } - - // Update button - if (plugin.canUpdateGit) { - OutlinedButton( - onClick = { viewModel.updatePlugin(plugin.name) }, - enabled = !busy, - ) { - Text(stringResource(R.string.plugins_action_update)) - } - Spacer(modifier = Modifier.width(8.dp)) - } - - // Remove button with confirmation - if (plugin.canRemove) { - OutlinedButton( - onClick = { viewModel.requestRemovePlugin(plugin.name) }, - enabled = !busy, - colors = - ButtonDefaults.outlinedButtonColors( - contentColor = MaterialTheme.colorScheme.error, - ), - ) { - Icon( - Icons.Default.Delete, - contentDescription = null, - modifier = Modifier.size(16.dp), - ) - Spacer(modifier = Modifier.width(4.dp)) - Text(stringResource(R.string.plugins_action_uninstall)) - } - } - } else { + } + if (!plugin.installed) { Button( onClick = { viewModel.activatePlugin(plugin) }, enabled = !busy, ) { - Text(stringResource(R.string.plugins_action_install)) + Text(stringResource(R.string.plugins_action_enable)) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt index bcbc4e6b..10d50bde 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsViewModel.kt @@ -14,10 +14,12 @@ import com.m57.hermescontrol.data.model.ProviderOption import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.safeApiCall +import com.m57.hermescontrol.data.ws.PluginRemovalRepository import com.m57.hermescontrol.ui.common.ToastHost import com.m57.hermescontrol.ui.common.safeLaunchAction import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -72,15 +74,18 @@ data class PluginsUiState( } } -class PluginsViewModel : - ViewModel(), +class PluginsViewModel( + private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(), +) : ViewModel(), ToastHost { private val _uiState = MutableStateFlow(PluginsUiState()) val uiState: StateFlow = _uiState.asStateFlow() private val pluginsCache = SwrCache() + private var scopeGeneration = 0 fun clearScopeOwnedState() { + scopeGeneration++ _uiState.update { it.copy( isLoading = false, @@ -333,26 +338,24 @@ class PluginsViewModel : } fun activatePlugin(plugin: PluginInfo) { - viewModelScope.launch { - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } - } - when (result) { - is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Plugin enabled successfully") } - loadPlugins(forceRefresh = true) - } - - is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") } - } - } - } + if (_uiState.value.rowBusy != null) return + safeLaunchAction( + onStart = { setRowBusy(plugin.name) }, + apiCall = { safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } }, + onSuccess = { + _uiState.update { it.copy(toastMessage = "Plugin enabled successfully") } + loadPlugins(forceRefresh = true) + }, + onError = { error -> + _uiState.update { it.copy(toastMessage = "Failed to enable plugin: $error") } + }, + onComplete = { clearRowBusy(plugin.name) }, + ) } /** Show confirmation dialog for removing a plugin */ fun requestRemovePlugin(name: String) { + if (_uiState.value.plugins.none { it.name == name && it.removable }) return _uiState.update { it.copy(removeConfirmPlugin = name) } } @@ -363,24 +366,85 @@ class PluginsViewModel : fun confirmRemovePlugin() { val name = _uiState.value.removeConfirmPlugin ?: return + if (_uiState.value.plugins.none { it.name == name && it.removable } || _uiState.value.rowBusy != null) { + cancelRemovePlugin() + return + } _uiState.update { it.copy(removeConfirmPlugin = null) } + val generation = scopeGeneration + val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull() + setRowBusy(name) viewModelScope.launch { - setRowBusy(name) - val result = - withContext(Dispatchers.IO) { - safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) } + try { + val result = pluginRemoval.remove(name) + if (generation != scopeGeneration || + (requestScope != null && AuthManager.currentDataScope() != requestScope) + ) { + return@launch } - when (result) { - is NetworkResult.Success -> { - _uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") } - clearRowBusy(name) - loadPlugins(forceRefresh = true) + if (!result.ok) { + _uiState.update { + it.copy( + toastMessage = "Failed to uninstall plugin: ${result.error ?: "Gateway refused removal"}", + ) + } + return@launch } + val refreshed = safeApiCall { ApiClient.hermesApi.getPlugins() } + if (generation != scopeGeneration || + (requestScope != null && AuthManager.currentDataScope() != requestScope) + ) { + return@launch + } + when (refreshed) { + is NetworkResult.Success -> { + val data = refreshed.data + requestScope?.let { pluginsCache.put(it.scopedKey("default"), data) } + _uiState.update { state -> + state.copy( + plugins = data.plugins, + orphanPlugins = + data.orphanDashboardPlugins.map { orphan -> + PluginInfo( + name = orphan.name ?: "unknown", + description = + orphan.description ?: orphan.label, + ) + }, + memoryProvider = data.providers?.memoryProvider ?: "", + memoryOptions = data.providers?.memoryOptions.orEmpty(), + contextEngine = data.providers?.contextEngine ?: "compressor", + contextOptions = data.providers?.contextOptions.orEmpty(), + toastMessage = + if (data.plugins.none { it.name == name }) { + "Plugin uninstalled successfully" + } else { + "Removal returned success, but $name still appears in the plugin list" + }, + ) + } + } - is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") } - clearRowBusy(name) + is NetworkResult.Failure -> { + _uiState.update { + it.copy( + toastMessage = "Removal sent; could not verify plugin list: ${refreshed.error.message}", + ) + } + } } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + if (generation == scopeGeneration) { + _uiState.update { + it.copy( + toastMessage = "Failed to uninstall plugin: ${e.message ?: "Gateway unavailable"}", + ) + } + } + } finally { + if (generation == scopeGeneration) clearRowBusy(name) } } } diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 2051265b..5b7a442f 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -942,6 +942,7 @@ لا يوجد وصف مقدم. تحديث إلغاء التثبيت +تفعيل تثبيت إزالة تثبيت إضافة diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index cff47d0a..20b1d7fc 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -794,6 +794,7 @@ 설명이 없습니다. 업데이트 제거 + 활성화 설치 제거 플러그인 설치 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index c4dcb11c..f72c2a80 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -909,6 +909,7 @@ 无描述。 更新 卸载 + 启用 安装 移除 安装插件 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9b8a1b77..c3dffc60 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1021,6 +1021,7 @@ No description provided. Update Uninstall + Enable Install Remove Install Plugin diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt new file mode 100644 index 00000000..81a6d21f --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/PluginRemovalRepositoryTest.kt @@ -0,0 +1,48 @@ +package com.m57.hermescontrol.data.ws + +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.SerializationException +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class PluginRemovalRepositoryTest { + @Test + fun `remove sends scoped gateway action and decodes success`() = + runTest { + var method = "" + var params: Map = emptyMap() + val repository = + PluginRemovalRepository { requestedMethod, requestedParams -> + method = requestedMethod + params = requestedParams + Json.parseToJsonElement("""{"ok":true,"name":"demo"}""") + } + + val result = repository.remove("demo") + + assertEquals(WsMethods.PLUGINS_MANAGE, method) + assertTrue(WsMethods.PLUGINS_MANAGE in WsMethods.PROFILE_SCOPED_METHODS) + assertEquals(mapOf("action" to "remove", "name" to "demo"), params) + assertTrue(result.ok) + assertEquals("demo", result.name) + } + + @Test + fun `refusal is a typed failure and missing ok cannot masquerade as success`() = + runTest { + val refused = PluginRemovalRepository { _, _ -> mapOf("ok" to false, "error" to "bundled plugin") } + assertFalse(refused.remove("builtin").ok) + assertEquals("bundled plugin", refused.remove("builtin").error) + + val malformed = PluginRemovalRepository { _, _ -> mapOf("name" to "demo") } + try { + malformed.remove("demo") + throw AssertionError("Missing ok must fail decoding") + } catch (_: SerializationException) { + // A partial response is not removal confirmation. + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt index 010da388..7c8c759f 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/plugins/PluginsViewModelTest.kt @@ -8,12 +8,15 @@ import com.m57.hermescontrol.data.model.PluginInfo import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.HermesApiService +import com.m57.hermescontrol.data.ws.PluginRemovalRepository +import com.m57.hermescontrol.data.ws.PluginRemovalResult import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkAll +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -34,6 +37,8 @@ import retrofit2.Response class PluginsViewModelTest { private val testDispatcher = StandardTestDispatcher() private lateinit var mockApi: HermesApiService + private val removablePlugin = + PluginInfo(name = "snyk", runtimeStatus = "inactive", canRemove = true, source = "user") private val sampleCatalogEntry = PluginCatalogEntry( @@ -191,4 +196,178 @@ class PluginsViewModelTest { ?.contains("Failed to install") == true, ) } + + @Test + fun `inactive hub plugin enables without invoking install`() { + coEvery { mockApi.enablePlugin("snyk") } returns Response.success(Unit) + coEvery { mockApi.getPlugins() } returns + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin.copy(runtimeStatus = "enabled")))) + val viewModel = PluginsViewModel() + + viewModel.activatePlugin(removablePlugin) + assertEquals("snyk", viewModel.uiState.value.rowBusy) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.plugins + .single() + .enabled, + ) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 1) { mockApi.enablePlugin("snyk") } + coVerify(exactly = 0) { mockApi.installPlugin(any()) } + } + + @Test + fun `inactive plugin can be removed after confirmation and verified refresh`() { + coEvery { mockApi.getPlugins() } returnsMany + listOf( + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))), + Response.success(PluginsHubResponse(plugins = emptyList())), + ) + val removal = mockk() + coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = true, name = "snyk") + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + assertEquals("snyk", viewModel.uiState.value.removeConfirmPlugin) + viewModel.confirmRemovePlugin() + assertEquals("snyk", viewModel.uiState.value.rowBusy) + assertEquals(1, viewModel.uiState.value.plugins.size) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.plugins + .isEmpty(), + ) + assertEquals("Plugin uninstalled successfully", viewModel.uiState.value.toastMessage) + assertNull(viewModel.uiState.value.rowBusy) + coVerify(exactly = 1) { removal.remove("snyk") } + coVerify(exactly = 2) { mockApi.getPlugins() } + } + + @Test + fun `built in plugin cannot enter removal flow even if asked directly`() { + coEvery { mockApi.getPlugins() } returns + Response.success( + PluginsHubResponse( + plugins = listOf(PluginInfo(name = "builtin", source = "bundled", canRemove = true)), + ), + ) + val removal = mockk() + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("builtin") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + + assertNull(viewModel.uiState.value.removeConfirmPlugin) + coVerify(exactly = 0) { removal.remove(any()) } + } + + @Test + fun `gateway refusal and disconnection leave plugin in list with reason`() { + coEvery { mockApi.getPlugins() } returns + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))) + val removal = mockk() + coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = false, error = "not a user install") + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.plugins.size) + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("not a user install"), + ) + + coEvery { removal.remove("snyk") } throws IllegalStateException("socket disconnected") + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + assertEquals(1, viewModel.uiState.value.plugins.size) + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("socket disconnected"), + ) + coVerify(exactly = 1) { mockApi.getPlugins() } + } + + @Test + fun `success reply without verified removal retains row and reports mismatch`() { + coEvery { mockApi.getPlugins() } returns + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))) + val removal = mockk() + coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = true, name = "snyk") + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.plugins.size) + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("still appears"), + ) + } + + @Test + fun `failed verification preserves row without success toast`() { + coEvery { mockApi.getPlugins() } returnsMany + listOf( + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))), + Response.error(503, "offline".toResponseBody()), + ) + val removal = mockk() + coEvery { removal.remove("snyk") } returns PluginRemovalResult(ok = true, name = "snyk") + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.advanceUntilIdle() + + assertEquals(1, viewModel.uiState.value.plugins.size) + assertTrue( + viewModel.uiState.value.toastMessage!! + .contains("could not verify"), + ) + } + + @Test + fun `scope switch discards late remove reply`() { + coEvery { mockApi.getPlugins() } returns + Response.success(PluginsHubResponse(plugins = listOf(removablePlugin))) + val reply = CompletableDeferred() + val removal = mockk() + coEvery { removal.remove("snyk") } coAnswers { reply.await() } + val viewModel = PluginsViewModel(removal) + viewModel.loadPlugins() + testDispatcher.scheduler.advanceUntilIdle() + + viewModel.requestRemovePlugin("snyk") + viewModel.confirmRemovePlugin() + testDispatcher.scheduler.runCurrent() + viewModel.clearScopeOwnedState() + reply.complete(PluginRemovalResult(ok = true, name = "snyk")) + testDispatcher.scheduler.advanceUntilIdle() + + assertTrue( + viewModel.uiState.value.plugins + .isEmpty(), + ) + assertNull(viewModel.uiState.value.toastMessage) + coVerify(exactly = 1) { mockApi.getPlugins() } + } }