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() =
runtimeStatus.equals("enabled", ignoreCase = true) ||
runtimeStatus.equals("disabled", ignoreCase = true)
val removable: Boolean
get() = canRemove && (source == "user" || source == "git")
}
@Serializable
@@ -665,11 +665,6 @@ interface HermesApiService : KanbanApiService {
@Body body: AgentPluginInstallBody,
): 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")
suspend fun updatePlugin(
@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. */
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
@@ -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))
}
}
}
@@ -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<PluginsUiState> = _uiState.asStateFlow()
private val pluginsCache = SwrCache<String, PluginsHubResponse>()
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)
}
}
}
+1
View File
@@ -942,6 +942,7 @@
<string name="plugins_no_desc">لا يوجد وصف مقدم.</string>
<string name="plugins_action_update">تحديث</string>
<string name="plugins_action_uninstall">إلغاء التثبيت</string>
<string name="plugins_action_enable">تفعيل</string>
<string name="plugins_action_install">تثبيت</string>
<string name="plugins_action_remove">إزالة</string>
<string name="plugins_install_heading">تثبيت إضافة</string>
+1
View File
@@ -794,6 +794,7 @@
<string name="plugins_no_desc">설명이 없습니다.</string>
<string name="plugins_action_update">업데이트</string>
<string name="plugins_action_uninstall">제거</string>
<string name="plugins_action_enable">활성화</string>
<string name="plugins_action_install">설치</string>
<string name="plugins_action_remove">제거</string>
<string name="plugins_install_heading">플러그인 설치</string>
+1
View File
@@ -909,6 +909,7 @@
<string name="plugins_no_desc">无描述。</string>
<string name="plugins_action_update">更新</string>
<string name="plugins_action_uninstall">卸载</string>
<string name="plugins_action_enable">启用</string>
<string name="plugins_action_install">安装</string>
<string name="plugins_action_remove">移除</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_action_update">Update</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_remove">Remove</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.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<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() }
}
}