fix(#1230,#1221): clarify inactive plugins and verify uninstall (#1236)

This commit is contained in:
m57 authored and GitHub committed 2026-09-23 12:25:21 +03:00
1 parent 721edef618
commit 33d87a11ec
13 files changed
+475 -94

No files matched your search

@@ -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()
}
}
@@ -24,6 +24,9 @@ data class PluginInfo(
get() = get() =
runtimeStatus.equals("enabled", ignoreCase = true) || runtimeStatus.equals("enabled", ignoreCase = true) ||
runtimeStatus.equals("disabled", ignoreCase = true) runtimeStatus.equals("disabled", ignoreCase = true)
val removable: Boolean
get() = canRemove && (source == "user" || source == "git")
} }
@Serializable @Serializable
@@ -665,11 +665,6 @@ interface HermesApiService : KanbanApiService {
@Body body: AgentPluginInstallBody, @Body body: AgentPluginInstallBody,
): Response<Unit> ): Response<Unit>
@DELETE("api/dashboard/agent-plugins/{name}")
suspend fun uninstallPlugin(
@Path("name", encoded = true) name: String,
): Response<Unit>
@POST("api/dashboard/agent-plugins/{name}/update") @POST("api/dashboard/agent-plugins/{name}/update")
suspend fun updatePlugin( suspend fun updatePlugin(
@Path("name", encoded = true) name: String, @Path("name", encoded = true) name: String,
@@ -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<String, Any>) -> 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<PluginRemovalResult>(result.toJsonElement())
}
}
@@ -110,6 +110,9 @@ object WsMethods {
/** Initiate or restart authorization flow for one or more connectors. */ /** Initiate or restart authorization flow for one or more connectors. */
const val CONNECTORS_CONNECT = "connectors.connect" const val CONNECTORS_CONNECT = "connectors.connect"
// ── Plugins ───────────────────────────────────────────────────────────
const val PLUGINS_MANAGE = "plugins.manage"
// ── Billing / subscription (issue #628) ───────────────────────────── // ── Billing / subscription (issue #628) ─────────────────────────────
// Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e). // Adopted from the backend release audit (hermes-agent 0bf44d557..614dc194e).
// `credits.view` was REMOVED upstream; these replace it. `usage.bars` // `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 CONFIG_SET, // methods_config_set.py: @method("config.set") + @_profile_scoped
"verification.status", // line 282 "verification.status", // line 282
PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped
PLUGINS_MANAGE, // methods_tools.py: @_scoped_rpc
"pet.info", // line 1253 "pet.info", // line 1253
"pet.info.meta", // line 1279 "pet.info.meta", // line 1279
"pet.cells", // line 1302 "pet.cells", // line 1302
@@ -486,7 +486,7 @@ private fun InstallSection(
} }
@Composable @Composable
private fun PluginCard( internal fun PluginCard(
plugin: PluginInfo, plugin: PluginInfo,
state: PluginsUiState, state: PluginsUiState,
viewModel: PluginsViewModel, viewModel: PluginsViewModel,
@@ -542,12 +542,7 @@ private fun PluginCard(
// Status badges // Status badges
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
val statusColor = val statusColor = if (plugin.enabled) statusColors.success else statusColors.warning
when {
plugin.enabled -> statusColors.success
plugin.installed -> statusColors.warning
else -> statusColors.error
}
Text( Text(
text = plugin.runtimeStatus ?: "inactive", text = plugin.runtimeStatus ?: "inactive",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
@@ -591,64 +586,63 @@ private fun PluginCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
) { ) {
if (plugin.installed) { // Visibility toggle (only for active plugins with dashboard manifests)
// Visibility toggle (only for plugins with dashboard manifest) if (plugin.installed && plugin.hasDashboardManifest) {
if (plugin.hasDashboardManifest) { IconButton(
IconButton( onClick = { viewModel.togglePluginVisibility(plugin) },
onClick = { viewModel.togglePluginVisibility(plugin) }, enabled = !busy,
enabled = !busy, modifier = Modifier.size(32.dp),
modifier = Modifier.size(32.dp), ) {
) { Icon(
Icon( if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility,
if (plugin.userHidden) Icons.Default.VisibilityOff else Icons.Default.Visibility, contentDescription =
contentDescription = if (plugin.userHidden) {
if (plugin.userHidden) { stringResource(R.string.plugins_show_in_sidebar)
stringResource(R.string.plugins_show_in_sidebar) } else {
} else { stringResource(R.string.plugins_hide_from_sidebar)
stringResource(R.string.plugins_hide_from_sidebar) },
}, modifier = Modifier.size(18.dp),
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)) Spacer(modifier = Modifier.width(4.dp))
Text(stringResource(R.string.plugins_action_uninstall))
} }
}
// Update button if (!plugin.installed) {
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 {
Button( Button(
onClick = { viewModel.activatePlugin(plugin) }, onClick = { viewModel.activatePlugin(plugin) },
enabled = !busy, enabled = !busy,
) { ) {
Text(stringResource(R.string.plugins_action_install)) Text(stringResource(R.string.plugins_action_enable))
} }
} }
} }
@@ -14,10 +14,12 @@ import com.m57.hermescontrol.data.model.ProviderOption
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.NetworkResult import com.m57.hermescontrol.data.remote.NetworkResult
import com.m57.hermescontrol.data.remote.safeApiCall 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.ToastHost
import com.m57.hermescontrol.ui.common.safeLaunchAction import com.m57.hermescontrol.ui.common.safeLaunchAction
import com.m57.hermescontrol.ui.common.safeLaunchLoad import com.m57.hermescontrol.ui.common.safeLaunchLoad
import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad import com.m57.hermescontrol.ui.common.safeLaunchSwrLoad
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@@ -72,15 +74,18 @@ data class PluginsUiState(
} }
} }
class PluginsViewModel : class PluginsViewModel(
ViewModel(), private val pluginRemoval: PluginRemovalRepository = PluginRemovalRepository(),
) : ViewModel(),
ToastHost { ToastHost {
private val _uiState = MutableStateFlow(PluginsUiState()) private val _uiState = MutableStateFlow(PluginsUiState())
val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow() val uiState: StateFlow<PluginsUiState> = _uiState.asStateFlow()
private val pluginsCache = SwrCache<String, PluginsHubResponse>() private val pluginsCache = SwrCache<String, PluginsHubResponse>()
private var scopeGeneration = 0
fun clearScopeOwnedState() { fun clearScopeOwnedState() {
scopeGeneration++
_uiState.update { _uiState.update {
it.copy( it.copy(
isLoading = false, isLoading = false,
@@ -333,26 +338,24 @@ class PluginsViewModel :
} }
fun activatePlugin(plugin: PluginInfo) { fun activatePlugin(plugin: PluginInfo) {
viewModelScope.launch { if (_uiState.value.rowBusy != null) return
val result = safeLaunchAction(
withContext(Dispatchers.IO) { onStart = { setRowBusy(plugin.name) },
safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } apiCall = { safeApiCall { ApiClient.hermesApi.enablePlugin(plugin.name) } },
} onSuccess = {
when (result) { _uiState.update { it.copy(toastMessage = "Plugin enabled successfully") }
is NetworkResult.Success -> { loadPlugins(forceRefresh = true)
_uiState.update { it.copy(toastMessage = "Plugin enabled successfully") } },
loadPlugins(forceRefresh = true) onError = { error ->
} _uiState.update { it.copy(toastMessage = "Failed to enable plugin: $error") }
},
is NetworkResult.Failure -> { onComplete = { clearRowBusy(plugin.name) },
_uiState.update { it.copy(toastMessage = "Failed to enable plugin: ${result.error.message}") } )
}
}
}
} }
/** Show confirmation dialog for removing a plugin */ /** Show confirmation dialog for removing a plugin */
fun requestRemovePlugin(name: String) { fun requestRemovePlugin(name: String) {
if (_uiState.value.plugins.none { it.name == name && it.removable }) return
_uiState.update { it.copy(removeConfirmPlugin = name) } _uiState.update { it.copy(removeConfirmPlugin = name) }
} }
@@ -363,24 +366,85 @@ class PluginsViewModel :
fun confirmRemovePlugin() { fun confirmRemovePlugin() {
val name = _uiState.value.removeConfirmPlugin ?: return 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) } _uiState.update { it.copy(removeConfirmPlugin = null) }
val generation = scopeGeneration
val requestScope = runCatching { AuthManager.currentDataScope() }.getOrNull()
setRowBusy(name)
viewModelScope.launch { viewModelScope.launch {
setRowBusy(name) try {
val result = val result = pluginRemoval.remove(name)
withContext(Dispatchers.IO) { if (generation != scopeGeneration ||
safeApiCall { ApiClient.hermesApi.uninstallPlugin(name) } (requestScope != null && AuthManager.currentDataScope() != requestScope)
) {
return@launch
} }
when (result) { if (!result.ok) {
is NetworkResult.Success -> { _uiState.update {
_uiState.update { it.copy(toastMessage = "Plugin uninstalled successfully") } it.copy(
clearRowBusy(name) toastMessage = "Failed to uninstall plugin: ${result.error ?: "Gateway refused removal"}",
loadPlugins(forceRefresh = true) )
}
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 -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "Failed to uninstall plugin: ${result.error.message}") } _uiState.update {
clearRowBusy(name) 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)
} }
} }
} }
+1
View File
@@ -942,6 +942,7 @@
<string name="plugins_no_desc">لا يوجد وصف مقدم.</string> <string name="plugins_no_desc">لا يوجد وصف مقدم.</string>
<string name="plugins_action_update">تحديث</string> <string name="plugins_action_update">تحديث</string>
<string name="plugins_action_uninstall">إلغاء التثبيت</string> <string name="plugins_action_uninstall">إلغاء التثبيت</string>
<string name="plugins_action_enable">تفعيل</string>
<string name="plugins_action_install">تثبيت</string> <string name="plugins_action_install">تثبيت</string>
<string name="plugins_action_remove">إزالة</string> <string name="plugins_action_remove">إزالة</string>
<string name="plugins_install_heading">تثبيت إضافة</string> <string name="plugins_install_heading">تثبيت إضافة</string>
+1
View File
@@ -794,6 +794,7 @@
<string name="plugins_no_desc">설명이 없습니다.</string> <string name="plugins_no_desc">설명이 없습니다.</string>
<string name="plugins_action_update">업데이트</string> <string name="plugins_action_update">업데이트</string>
<string name="plugins_action_uninstall">제거</string> <string name="plugins_action_uninstall">제거</string>
<string name="plugins_action_enable">활성화</string>
<string name="plugins_action_install">설치</string> <string name="plugins_action_install">설치</string>
<string name="plugins_action_remove">제거</string> <string name="plugins_action_remove">제거</string>
<string name="plugins_install_heading">플러그인 설치</string> <string name="plugins_install_heading">플러그인 설치</string>
+1
View File
@@ -909,6 +909,7 @@
<string name="plugins_no_desc">无描述。</string> <string name="plugins_no_desc">无描述。</string>
<string name="plugins_action_update">更新</string> <string name="plugins_action_update">更新</string>
<string name="plugins_action_uninstall">卸载</string> <string name="plugins_action_uninstall">卸载</string>
<string name="plugins_action_enable">启用</string>
<string name="plugins_action_install">安装</string> <string name="plugins_action_install">安装</string>
<string name="plugins_action_remove">移除</string> <string name="plugins_action_remove">移除</string>
<string name="plugins_install_heading">安装插件</string> <string name="plugins_install_heading">安装插件</string>
+1
View File
@@ -1021,6 +1021,7 @@
<string name="plugins_no_desc">No description provided.</string> <string name="plugins_no_desc">No description provided.</string>
<string name="plugins_action_update">Update</string> <string name="plugins_action_update">Update</string>
<string name="plugins_action_uninstall">Uninstall</string> <string name="plugins_action_uninstall">Uninstall</string>
<string name="plugins_action_enable">Enable</string>
<string name="plugins_action_install">Install</string> <string name="plugins_action_install">Install</string>
<string name="plugins_action_remove">Remove</string> <string name="plugins_action_remove">Remove</string>
<string name="plugins_install_heading">Install Plugin</string> <string name="plugins_install_heading">Install Plugin</string>
@@ -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<String, Any> = 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.
}
}
}
@@ -8,12 +8,15 @@ import com.m57.hermescontrol.data.model.PluginInfo
import com.m57.hermescontrol.data.model.PluginsHubResponse import com.m57.hermescontrol.data.model.PluginsHubResponse
import com.m57.hermescontrol.data.remote.ApiClient import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.data.remote.HermesApiService 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.coEvery
import io.mockk.coVerify import io.mockk.coVerify
import io.mockk.every import io.mockk.every
import io.mockk.mockk import io.mockk.mockk
import io.mockk.mockkObject import io.mockk.mockkObject
import io.mockk.unmockkAll import io.mockk.unmockkAll
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@@ -34,6 +37,8 @@ import retrofit2.Response
class PluginsViewModelTest { class PluginsViewModelTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService private lateinit var mockApi: HermesApiService
private val removablePlugin =
PluginInfo(name = "snyk", runtimeStatus = "inactive", canRemove = true, source = "user")
private val sampleCatalogEntry = private val sampleCatalogEntry =
PluginCatalogEntry( PluginCatalogEntry(
@@ -191,4 +196,178 @@ class PluginsViewModelTest {
?.contains("Failed to install") == true, ?.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<PluginRemovalRepository>()
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<PluginRemovalRepository>()
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<PluginRemovalRepository>()
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<PluginRemovalRepository>()
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<PluginRemovalRepository>()
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<PluginRemovalResult>()
val removal = mockk<PluginRemovalRepository>()
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() }
}
} }