feat(plugins): browse and install official plugins from catalog (#1088) (#1096)

This commit is contained in:
M57 authored and GitHub committed 2026-09-12 22:32:52 +03:00
1 parent 47b6955c9a
commit d774a63663
8 files changed
+840 -71

No files matched your search

@@ -0,0 +1,194 @@
package com.m57.hermescontrol.ui.plugins
import com.m57.hermescontrol.data.model.AgentPluginInstallBody
import com.m57.hermescontrol.data.model.PluginCatalogCapabilities
import com.m57.hermescontrol.data.model.PluginCatalogEntry
import com.m57.hermescontrol.data.model.PluginCatalogResponse
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 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.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.setMain
import okhttp3.ResponseBody.Companion.toResponseBody
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import retrofit2.Response
@OptIn(ExperimentalCoroutinesApi::class)
class PluginsViewModelTest {
private val testDispatcher = StandardTestDispatcher()
private lateinit var mockApi: HermesApiService
private val sampleCatalogEntry =
PluginCatalogEntry(
name = "snyk",
catalogName = "snyk",
repo = "https://github.com/NousResearch/hermes-plugin-snyk",
sha = "30e0adfeaa181190bc1f81cf434a94aa6a56015f",
shaShort = "30e0adf",
description = "Security scanning for dependencies and code.",
maintainer = "NousResearch",
tier = "official",
capabilities =
PluginCatalogCapabilities(
providesTools = listOf("snyk_test"),
),
installed = false,
updateAvailable = false,
)
@Before
fun setUp() {
Dispatchers.setMain(testDispatcher)
mockApi = mockk()
mockkObject(ApiClient)
every { ApiClient.hermesApi } returns mockApi
}
@After
fun tearDown() {
Dispatchers.resetMain()
unmockkAll()
}
@Test
fun `switch tab to CATALOG automatically triggers catalog load`() {
coEvery { mockApi.getPluginCatalog() } returns
Response.success(PluginCatalogResponse(entries = listOf(sampleCatalogEntry)))
val viewModel = PluginsViewModel()
viewModel.setTab(PluginsTab.CATALOG)
assertEquals(PluginsTab.CATALOG, viewModel.uiState.value.selectedTab)
assertTrue(viewModel.uiState.value.isCatalogLoading)
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(viewModel.uiState.value.isCatalogLoading)
assertEquals(1, viewModel.uiState.value.catalogEntries.size)
assertEquals(
"snyk",
viewModel.uiState.value.catalogEntries
.first()
.name,
)
assertTrue(
viewModel.uiState.value.catalogEntries
.first()
.isOfficial,
)
assertNull(viewModel.uiState.value.catalogErrorMessage)
coVerify(exactly = 1) { mockApi.getPluginCatalog() }
}
@Test
fun `loadCatalog handles api error gracefully`() {
coEvery { mockApi.getPluginCatalog() } returns
Response.error(500, "Internal Server Error".toResponseBody())
val viewModel = PluginsViewModel()
viewModel.loadCatalog()
testDispatcher.scheduler.advanceUntilIdle()
assertFalse(viewModel.uiState.value.isCatalogLoading)
assertTrue(
viewModel.uiState.value.catalogEntries
.isEmpty(),
)
assertTrue(
viewModel.uiState.value.catalogErrorMessage
?.contains("500") == true,
)
}
@Test
fun `filter and query updates affect state`() {
val viewModel = PluginsViewModel()
viewModel.setCatalogQuery("security")
viewModel.setCatalogTierFilter("official")
assertEquals("security", viewModel.uiState.value.catalogQuery)
assertEquals("official", viewModel.uiState.value.catalogTierFilter)
}
@Test
fun `installCatalogPlugin executes install and refreshes lists`() {
coEvery {
mockApi.installPlugin(
AgentPluginInstallBody(
identifier = sampleCatalogEntry.repo ?: "",
catalogName = sampleCatalogEntry.name,
force = false,
enable = true,
),
)
} returns Response.success(Unit)
coEvery { mockApi.getPlugins() } returns
Response.success(PluginsHubResponse(plugins = listOf(PluginInfo(name = "snyk", runtimeStatus = "enabled"))))
coEvery { mockApi.getPluginCatalog() } returns
Response.success(
PluginCatalogResponse(
entries = listOf(sampleCatalogEntry.copy(installed = true)),
),
)
val viewModel = PluginsViewModel()
viewModel.installCatalogPlugin(sampleCatalogEntry)
assertEquals("snyk", viewModel.uiState.value.catalogInstallingName)
testDispatcher.scheduler.advanceUntilIdle()
assertNull(viewModel.uiState.value.catalogInstallingName)
assertEquals("Plugin \"snyk\" installed successfully", viewModel.uiState.value.toastMessage)
assertEquals(1, viewModel.uiState.value.plugins.size)
assertTrue(
viewModel.uiState.value.catalogEntries
.first()
.installed,
)
coVerify(exactly = 1) {
mockApi.installPlugin(
match { it.catalogName == "snyk" && it.enable && !it.force },
)
}
}
@Test
fun `installCatalogPlugin failure sets toast error`() {
coEvery {
mockApi.installPlugin(any())
} returns Response.error(400, "Clone failed".toResponseBody())
val viewModel = PluginsViewModel()
viewModel.installCatalogPlugin(sampleCatalogEntry)
testDispatcher.scheduler.advanceUntilIdle()
assertNull(viewModel.uiState.value.catalogInstallingName)
assertTrue(
viewModel.uiState.value.toastMessage
?.contains("Failed to install") == true,
)
}
}