fix(#1230,#1221): clarify inactive plugins and verify uninstall (#1236)
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user