This commit is contained in:
13 files changed
+1786
-10
No files matched your search
@@ -0,0 +1,376 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class PluginManageModelsTest {
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes and encodes string type correctly`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "api_host",
|
||||
"type": "string",
|
||||
"label": "API Host",
|
||||
"description": "Hostname for API",
|
||||
"required": true,
|
||||
"value": "https://api.example.com",
|
||||
"default": "https://localhost",
|
||||
"choices": null,
|
||||
"env": "API_HOST",
|
||||
"has_value": true
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("api_host", field.key)
|
||||
assertEquals(PluginSettingFieldType.STRING, field.type)
|
||||
assertEquals("API Host", field.label)
|
||||
assertEquals("Hostname for API", field.description)
|
||||
assertTrue(field.required)
|
||||
assertEquals(JsonPrimitive("https://api.example.com"), field.value)
|
||||
assertEquals(JsonPrimitive("https://localhost"), field.default)
|
||||
assertNull(field.choices)
|
||||
assertEquals("API_HOST", field.env)
|
||||
assertEquals(true, field.hasValue)
|
||||
|
||||
val encoded = OkHttpProvider.json.encodeToString(field)
|
||||
val roundTripped = OkHttpProvider.json.decodeFromString<PluginSettingField>(encoded)
|
||||
assertEquals(field, roundTripped)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes and encodes number type with numeric value and default`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "port",
|
||||
"type": "number",
|
||||
"label": "Port Number",
|
||||
"value": 8080,
|
||||
"default": 3000
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("port", field.key)
|
||||
assertEquals(PluginSettingFieldType.NUMBER, field.type)
|
||||
assertEquals(JsonPrimitive(8080), field.value)
|
||||
assertEquals(JsonPrimitive(3000), field.default)
|
||||
assertEquals("", field.description)
|
||||
assertFalse(field.required)
|
||||
assertNull(field.choices)
|
||||
assertNull(field.env)
|
||||
assertNull(field.hasValue)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes and encodes boolean type with default false`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "enabled",
|
||||
"type": "boolean",
|
||||
"label": "Enabled",
|
||||
"value": true,
|
||||
"default": false
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("enabled", field.key)
|
||||
assertEquals(PluginSettingFieldType.BOOLEAN, field.type)
|
||||
assertEquals(JsonPrimitive(true), field.value)
|
||||
assertEquals(JsonPrimitive(false), field.default)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes enum type with choices`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "mode",
|
||||
"type": "enum",
|
||||
"label": "Execution Mode",
|
||||
"choices": ["fast", "balanced", "thorough"],
|
||||
"value": "fast",
|
||||
"default": "balanced"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("mode", field.key)
|
||||
assertEquals(PluginSettingFieldType.ENUM, field.type)
|
||||
assertEquals(listOf("fast", "balanced", "thorough"), field.choices)
|
||||
assertEquals(JsonPrimitive("fast"), field.value)
|
||||
assertEquals(JsonPrimitive("balanced"), field.default)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes secret type with masked or null value and has_value flag`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "api_token",
|
||||
"type": "secret",
|
||||
"label": "API Token",
|
||||
"value": null,
|
||||
"env": "SECRET_TOKEN",
|
||||
"has_value": true
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("api_token", field.key)
|
||||
assertEquals(PluginSettingFieldType.SECRET, field.type)
|
||||
assertEquals(null, field.value)
|
||||
assertEquals("SECRET_TOKEN", field.env)
|
||||
assertEquals(true, field.hasValue)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types.
|
||||
@Test
|
||||
fun `PluginSettingField decodes json type with object or array values`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "extra_config",
|
||||
"type": "json",
|
||||
"label": "Extra Config",
|
||||
"value": {"timeout": 30, "retries": 3},
|
||||
"default": {}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val field = OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
assertEquals("extra_config", field.key)
|
||||
assertEquals(PluginSettingFieldType.JSON, field.type)
|
||||
assertTrue(field.value is JsonObject)
|
||||
val valueObj = field.value as JsonObject
|
||||
assertEquals(JsonPrimitive(30), valueObj["timeout"])
|
||||
assertEquals(JsonPrimitive(3), valueObj["retries"])
|
||||
assertTrue(field.default is JsonObject)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginSettingField with string, number, boolean, enum, secret, json types (error case).
|
||||
@Test
|
||||
fun `PluginSettingField decoding fails on unknown type value`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"key": "invalid_field",
|
||||
"type": "unknown_future_type",
|
||||
"label": "Invalid"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
try {
|
||||
OkHttpProvider.json.decodeFromString<PluginSettingField>(jsonString)
|
||||
fail("Expected SerializationException for unknown type")
|
||||
} catch (_: SerializationException) {
|
||||
// expected
|
||||
}
|
||||
}
|
||||
|
||||
// CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN).
|
||||
@Test
|
||||
fun `PluginServerState decodes all enum variants from snake_case strings`() {
|
||||
val expectedStates =
|
||||
mapOf(
|
||||
"connected" to PluginServerState.CONNECTED,
|
||||
"app_not_running" to PluginServerState.APP_NOT_RUNNING,
|
||||
"endpoint_unavailable" to PluginServerState.ENDPOINT_UNAVAILABLE,
|
||||
"no_interactive_session" to PluginServerState.NO_INTERACTIVE_SESSION,
|
||||
"version_too_old" to PluginServerState.VERSION_TOO_OLD,
|
||||
"missing_app" to PluginServerState.MISSING_APP,
|
||||
"unknown" to PluginServerState.UNKNOWN,
|
||||
)
|
||||
|
||||
for ((serialized, expectedEnum) in expectedStates) {
|
||||
val decoded = OkHttpProvider.json.decodeFromString<PluginServerState>("\"$serialized\"")
|
||||
assertEquals("Mismatch for $serialized", expectedEnum, decoded)
|
||||
val reEncoded = OkHttpProvider.json.encodeToString(expectedEnum)
|
||||
assertEquals("\"$serialized\"", reEncoded)
|
||||
}
|
||||
}
|
||||
|
||||
// CONTRACT: PluginServerState (CONNECTED, APP_NOT_RUNNING, ENDPOINT_UNAVAILABLE, VERSION_TOO_OLD, MISSING_APP, UNKNOWN) via PluginServerRow.
|
||||
@Test
|
||||
fun `PluginServerRow decodes name state and sentence correctly`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"name": "camofox_service",
|
||||
"state": "app_not_running",
|
||||
"sentence": "Camofox browser is not running on port 9333."
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = OkHttpProvider.json.decodeFromString<PluginServerRow>(jsonString)
|
||||
assertEquals("camofox_service", row.name)
|
||||
assertEquals(PluginServerState.APP_NOT_RUNNING, row.state)
|
||||
assertEquals("Camofox browser is not running on port 9333.", row.sentence)
|
||||
}
|
||||
|
||||
// CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape.
|
||||
@Test
|
||||
fun `AgentPluginRow decodes full payload shape with servers and settings schema`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"name": "camofox",
|
||||
"key": "camofox-browser",
|
||||
"version": "1.2.0",
|
||||
"description": "Anti-detect browser automation",
|
||||
"source": "installed",
|
||||
"status": "active",
|
||||
"portable": true,
|
||||
"install_dir": "/home/user/.hermes/plugins/camofox",
|
||||
"has_desktop_half": true,
|
||||
"servers": [
|
||||
{
|
||||
"name": "cdp",
|
||||
"state": "connected",
|
||||
"sentence": "CDP connected"
|
||||
}
|
||||
],
|
||||
"settings_schema": [
|
||||
{
|
||||
"key": "port",
|
||||
"type": "number",
|
||||
"label": "CDP Port",
|
||||
"value": 9222
|
||||
}
|
||||
],
|
||||
"catalog_name": "camofox",
|
||||
"catalog_tier": "community",
|
||||
"installed_sha": "abc1234",
|
||||
"catalog_sha": "def5678",
|
||||
"catalog_version": "1.3.0",
|
||||
"update_available": true,
|
||||
"pinned_sha": "abc1234"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = OkHttpProvider.json.decodeFromString<AgentPluginRow>(jsonString)
|
||||
assertEquals("camofox", row.name)
|
||||
assertEquals("camofox-browser", row.key)
|
||||
assertEquals("1.2.0", row.version)
|
||||
assertEquals("Anti-detect browser automation", row.description)
|
||||
assertEquals("installed", row.source)
|
||||
assertEquals("status" to "active", "status" to row.status)
|
||||
assertTrue(row.portable)
|
||||
assertEquals("/home/user/.hermes/plugins/camofox", row.installDir)
|
||||
assertTrue(row.hasDesktopHalf)
|
||||
assertEquals(1, row.servers.size)
|
||||
assertEquals("cdp", row.servers[0].name)
|
||||
assertEquals(PluginServerState.CONNECTED, row.servers[0].state)
|
||||
assertEquals(1, row.settingsSchema?.size)
|
||||
assertEquals("port", row.settingsSchema?.get(0)?.key)
|
||||
assertEquals(PluginSettingFieldType.NUMBER, row.settingsSchema?.get(0)?.type)
|
||||
assertEquals("camofox", row.catalogName)
|
||||
assertEquals("community", row.catalogTier)
|
||||
assertEquals("abc1234", row.installedSha)
|
||||
assertEquals("def5678", row.catalogSha)
|
||||
assertEquals("1.3.0", row.catalogVersion)
|
||||
assertEquals(true, row.updateAvailable)
|
||||
assertEquals("abc1234", row.pinnedSha)
|
||||
}
|
||||
|
||||
// CONTRACT: AgentPluginRow decoding from backend plugins.manage list payload shape (defaults/nulls).
|
||||
@Test
|
||||
fun `AgentPluginRow decodes minimal required payload using defaults`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"name": "minimal_plugin"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val row = OkHttpProvider.json.decodeFromString<AgentPluginRow>(jsonString)
|
||||
assertEquals("minimal_plugin", row.name)
|
||||
assertNull(row.key)
|
||||
assertEquals("", row.version)
|
||||
assertEquals("", row.description)
|
||||
assertEquals("", row.source)
|
||||
assertEquals("", row.status)
|
||||
assertFalse(row.portable)
|
||||
assertEquals("", row.installDir)
|
||||
assertFalse(row.hasDesktopHalf)
|
||||
assertTrue(row.servers.isEmpty())
|
||||
assertNull(row.settingsSchema)
|
||||
assertNull(row.catalogName)
|
||||
assertNull(row.catalogTier)
|
||||
assertNull(row.installedSha)
|
||||
assertNull(row.catalogSha)
|
||||
assertNull(row.catalogVersion)
|
||||
assertNull(row.updateAvailable)
|
||||
assertNull(row.pinnedSha)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginsManageSettingsResult decoding happy path and error cases.
|
||||
@Test
|
||||
fun `PluginsManageSettingsResult decodes success with updated plugin and written keys`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"name": "camofox",
|
||||
"written": ["port", "headless"],
|
||||
"plugin": {
|
||||
"name": "camofox",
|
||||
"version": "1.2.0"
|
||||
},
|
||||
"error": null
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = OkHttpProvider.json.decodeFromString<PluginsManageSettingsResult>(jsonString)
|
||||
assertTrue(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals(listOf("port", "headless"), result.written)
|
||||
assertNotNull(result.plugin)
|
||||
assertEquals("camofox", result.plugin?.name)
|
||||
assertNull(result.error)
|
||||
}
|
||||
|
||||
// CONTRACT: PluginsManageSettingsResult decoding happy path and error cases.
|
||||
@Test
|
||||
fun `PluginsManageSettingsResult decodes failure result with error message`() {
|
||||
val jsonString =
|
||||
"""
|
||||
{
|
||||
"ok": false,
|
||||
"name": "camofox",
|
||||
"error": "Validation failed: port must be an integer"
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val result = OkHttpProvider.json.decodeFromString<PluginsManageSettingsResult>(jsonString)
|
||||
assertFalse(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals("Validation failed: port must be an integer", result.error)
|
||||
assertNull(result.written)
|
||||
assertNull(result.plugin)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class PluginManageRepositoryTest {
|
||||
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
|
||||
@Test
|
||||
fun `listPlugins without profile calls PLUGINS_MANAGE action list and decodes plugins list`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockPayload =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"plugins": [
|
||||
{
|
||||
"name": "plugin-alpha",
|
||||
"version": "1.0.0",
|
||||
"status": "active"
|
||||
},
|
||||
{
|
||||
"name": "plugin-beta",
|
||||
"version": "2.1.0",
|
||||
"status": "disabled"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockPayload
|
||||
}
|
||||
|
||||
val plugins = repository.listPlugins()
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals(mapOf("action" to "list"), capturedParams)
|
||||
assertEquals(2, plugins.size)
|
||||
assertEquals("plugin-alpha", plugins[0].name)
|
||||
assertEquals("1.0.0", plugins[0].version)
|
||||
assertEquals("active", plugins[0].status)
|
||||
assertEquals("plugin-beta", plugins[1].name)
|
||||
assertEquals("2.1.0", plugins[1].version)
|
||||
assertEquals("disabled", plugins[1].status)
|
||||
}
|
||||
|
||||
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
|
||||
@Test
|
||||
fun `listPlugins with non-blank profile includes profile parameter in request`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockPayload =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"plugins": []
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockPayload
|
||||
}
|
||||
|
||||
val plugins = repository.listPlugins(profile = "work")
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals(mapOf("action" to "list", "profile" to "work"), capturedParams)
|
||||
assertTrue(plugins.isEmpty())
|
||||
}
|
||||
|
||||
// CONTRACT: `listPlugins(profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "list"} (and profile if set) and decodes plugins list.
|
||||
@Test
|
||||
fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() =
|
||||
runTest {
|
||||
val emptyObjRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("{}")
|
||||
}
|
||||
assertTrue(emptyObjRepo.listPlugins().isEmpty())
|
||||
|
||||
val nonObjRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("[]")
|
||||
}
|
||||
assertTrue(nonObjRepo.listPlugins().isEmpty())
|
||||
|
||||
val nullRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
null
|
||||
}
|
||||
assertTrue(nullRepo.listPlugins().isEmpty())
|
||||
}
|
||||
|
||||
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result.
|
||||
@Test
|
||||
fun `saveSettings without profile calls PLUGINS_MANAGE with action settings key and JsonObject values`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockResponse =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"ok": true,
|
||||
"name": "camofox",
|
||||
"written": ["port", "headless"],
|
||||
"plugin": {
|
||||
"name": "camofox",
|
||||
"version": "1.0.0"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockResponse
|
||||
}
|
||||
|
||||
val valuesToSave: Map<String, JsonElement> =
|
||||
mapOf(
|
||||
"port" to JsonPrimitive(9222),
|
||||
"headless" to JsonPrimitive(true),
|
||||
)
|
||||
|
||||
val result = repository.saveSettings(key = "camofox", values = valuesToSave)
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals("settings", capturedParams["action"])
|
||||
assertEquals("camofox", capturedParams["key"])
|
||||
assertEquals(JsonObject(valuesToSave), capturedParams["values"])
|
||||
assertFalse(capturedParams.containsKey("profile"))
|
||||
|
||||
assertTrue(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals(listOf("port", "headless"), result.written)
|
||||
assertNotNull(result.plugin)
|
||||
assertEquals("camofox", result.plugin?.name)
|
||||
assertNull(result.error)
|
||||
}
|
||||
|
||||
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result.
|
||||
@Test
|
||||
fun `saveSettings with profile includes profile in request and handles error result`() =
|
||||
runTest {
|
||||
var capturedMethod = ""
|
||||
var capturedParams: Map<String, Any?> = emptyMap()
|
||||
|
||||
val mockResponse =
|
||||
OkHttpProvider.json.parseToJsonElement(
|
||||
"""
|
||||
{
|
||||
"ok": false,
|
||||
"name": "camofox",
|
||||
"error": "Port already in use"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockResponse
|
||||
}
|
||||
|
||||
val valuesToSave: Map<String, JsonElement> =
|
||||
mapOf(
|
||||
"port" to JsonPrimitive(8080),
|
||||
)
|
||||
|
||||
val result = repository.saveSettings(key = "camofox", values = valuesToSave, profile = "staging")
|
||||
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"action" to "settings",
|
||||
"key" to "camofox",
|
||||
"values" to JsonObject(valuesToSave),
|
||||
"profile" to "staging",
|
||||
),
|
||||
capturedParams,
|
||||
)
|
||||
|
||||
assertFalse(result.ok)
|
||||
assertEquals("camofox", result.name)
|
||||
assertEquals("Port already in use", result.error)
|
||||
assertNull(result.written)
|
||||
assertNull(result.plugin)
|
||||
}
|
||||
|
||||
// CONTRACT: `saveSettings(key, values, profile)` calls WsMethods.PLUGINS_MANAGE with {"action": "settings", "key": key, "values": values} and decodes result (error case).
|
||||
@Test
|
||||
fun `saveSettings throws SerializationException when response is malformed`() =
|
||||
runTest {
|
||||
val repository =
|
||||
PluginManageRepository { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}")
|
||||
}
|
||||
|
||||
try {
|
||||
repository.saveSettings("camofox", emptyMap())
|
||||
fail("Expected SerializationException when response JSON is malformed")
|
||||
} catch (_: SerializationException) {
|
||||
// Expected
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Tests verifying that persisted edit previews survive transcript loading into [ChatMessage]
|
||||
* and subsequent tool diff parsing via [parseToolOutput].
|
||||
*/
|
||||
class PersistedEditPreviewHydrationTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun parseJson(raw: String): JsonElement = json.parseToJsonElement(raw)
|
||||
|
||||
private fun toolSessionMessage(
|
||||
content: String,
|
||||
displayMetadata: JsonElement? = null,
|
||||
id: Int = 101,
|
||||
toolCallId: String = "call-101",
|
||||
): SessionMessage =
|
||||
SessionMessage(
|
||||
id = id,
|
||||
role = "tool",
|
||||
content = JsonPrimitive(content),
|
||||
tool_call_id = toolCallId,
|
||||
display_metadata = displayMetadata,
|
||||
)
|
||||
|
||||
private fun mapSingle(msg: SessionMessage): ChatMessage =
|
||||
mapServerMessages(
|
||||
sessionId = "sess-edit-preview",
|
||||
messages = listOf(msg),
|
||||
offset = 0,
|
||||
latestPaging = true,
|
||||
liveMessages = emptyList(),
|
||||
).single()
|
||||
|
||||
// ── Contract 1: ChatServerMessageMapper enrichment ────────────────────────
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_enrichesToolContentWithDisplayMetadataInlineDiff() {
|
||||
val diffPayload =
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val originalContent = """{"output":"File updated successfully"}"""
|
||||
val serverMsg =
|
||||
toolSessionMessage(
|
||||
content = originalContent,
|
||||
displayMetadata = parseJson(diffPayload),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
val enriched = parseJson(chatMessage.content) as? JsonObject
|
||||
assertNotNull("Expected enriched content to parse as JSON object", enriched)
|
||||
assertEquals(
|
||||
"File updated successfully",
|
||||
(enriched?.get("output") as? JsonPrimitive)?.content,
|
||||
)
|
||||
val displayMeta = enriched?.get("display_metadata") as? JsonObject
|
||||
assertNotNull("Expected display_metadata to be added to content", displayMeta)
|
||||
val toolResultMeta = displayMeta?.get("tool_result_metadata") as? JsonObject
|
||||
assertNotNull("Expected tool_result_metadata inside display_metadata", toolResultMeta)
|
||||
val diff = (toolResultMeta?.get("inline_diff") as? JsonPrimitive)?.content
|
||||
assertEquals("@@ -1 +1 @@\n-old\n+new", diff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_doesNotEnrichWhenDisplayMetadataIsNull() {
|
||||
val originalContent = """{"output":"no metadata"}"""
|
||||
val serverMsg =
|
||||
toolSessionMessage(
|
||||
content = originalContent,
|
||||
displayMetadata = null,
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
assertEquals(originalContent, chatMessage.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_doesNotEnrichWhenRoleIsNotTool() {
|
||||
val metadata =
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val originalContent = "Assistant says hello"
|
||||
val serverMsg =
|
||||
SessionMessage(
|
||||
id = 202,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(originalContent),
|
||||
display_metadata = parseJson(metadata),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
assertEquals(originalContent, chatMessage.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_doesNotOverwriteExistingDisplayMetadataInContent() {
|
||||
val content =
|
||||
"""
|
||||
{
|
||||
"output": "ok",
|
||||
"display_metadata": {
|
||||
"existing": true
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val externalMetadata =
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val serverMsg =
|
||||
toolSessionMessage(
|
||||
content = content,
|
||||
displayMetadata = parseJson(externalMetadata),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
val parsed = parseJson(chatMessage.content) as? JsonObject
|
||||
assertNotNull(parsed)
|
||||
val meta = parsed?.get("display_metadata") as? JsonObject
|
||||
assertEquals("true", (meta?.get("existing") as? JsonPrimitive)?.content)
|
||||
assertNull(meta?.get("tool_result_metadata"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mapServerMessages_ignoresNonJsonObjectContent() {
|
||||
val nonJsonContent = "Plain text output from tool"
|
||||
val metadata =
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-old\n+new"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val serverMsg =
|
||||
toolSessionMessage(
|
||||
content = nonJsonContent,
|
||||
displayMetadata = parseJson(metadata),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(serverMsg)
|
||||
|
||||
assertEquals(nonJsonContent, chatMessage.content)
|
||||
}
|
||||
|
||||
// ── Contract 2: ToolResultParser / FileRenderers inline diff extraction ──
|
||||
|
||||
@Test
|
||||
fun parseToolOutput_extractsInlineDiffFromNestedToolResultMetadata() {
|
||||
val rawDiff = "@@ -1,3 +1,3 @@\n-old line\n+new line"
|
||||
val content =
|
||||
"""
|
||||
{
|
||||
"name": "patch",
|
||||
"args": {
|
||||
"path": "app/src/main/Utils.kt"
|
||||
},
|
||||
"display_metadata": {
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1,3 +1,3 @@\n-old line\n+new line"
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val view = parseToolOutput(content = content, toolName = "patch", isRunning = false)
|
||||
|
||||
assertNotNull("Expected parseToolOutput to return a ToolView", view)
|
||||
assertEquals(rawDiff, view?.inlineDiff)
|
||||
assertEquals("app/src/main/Utils.kt", view?.diffPath)
|
||||
assertEquals(1, view?.diffStats?.added)
|
||||
assertEquals(1, view?.diffStats?.removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseToolOutput_extractsInlineDiffFromFlatDisplayMetadata() {
|
||||
val rawDiff = "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta"
|
||||
val content =
|
||||
"""
|
||||
{
|
||||
"name": "edit_file",
|
||||
"args": {
|
||||
"path": "test.kt"
|
||||
},
|
||||
"display_metadata": {
|
||||
"inline_diff": "--- a/test.kt\n+++ b/test.kt\n@@ -1 +1 @@\n-alpha\n+beta"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val view = parseToolOutput(content = content, toolName = "edit_file", isRunning = false)
|
||||
|
||||
assertNotNull(view)
|
||||
assertEquals(rawDiff, view?.inlineDiff)
|
||||
assertEquals("test.kt", view?.diffPath)
|
||||
assertEquals(1, view?.diffStats?.added)
|
||||
assertEquals(1, view?.diffStats?.removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseToolOutput_extractsInlineDiffFromResultObjectDirectly() {
|
||||
val rawDiff = "@@ -1 +1 @@\n-before\n+after"
|
||||
val content =
|
||||
"""
|
||||
{
|
||||
"name": "patch",
|
||||
"args": {
|
||||
"path": "file.txt"
|
||||
},
|
||||
"result": {
|
||||
"inline_diff": "@@ -1 +1 @@\n-before\n+after"
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val view = parseToolOutput(content = content, toolName = "patch", isRunning = false)
|
||||
|
||||
assertNotNull(view)
|
||||
assertEquals(rawDiff, view?.inlineDiff)
|
||||
assertEquals("file.txt", view?.diffPath)
|
||||
assertEquals(1, view?.diffStats?.added)
|
||||
assertEquals(1, view?.diffStats?.removed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseToolOutput_absentOrEmptyMetadataYieldsNullInlineDiff() {
|
||||
val emptyDiffContent =
|
||||
"""
|
||||
{
|
||||
"name": "patch",
|
||||
"args": {
|
||||
"path": "file.txt"
|
||||
},
|
||||
"display_metadata": {
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val noDiffView = parseToolOutput(content = emptyDiffContent, toolName = "patch", isRunning = false)
|
||||
assertNotNull(noDiffView)
|
||||
assertNull(noDiffView?.inlineDiff)
|
||||
|
||||
val missingDiffContent =
|
||||
"""
|
||||
{
|
||||
"name": "patch",
|
||||
"args": {
|
||||
"path": "file.txt"
|
||||
},
|
||||
"result": {
|
||||
"success": true
|
||||
}
|
||||
}
|
||||
""".trimIndent()
|
||||
val missingDiffView = parseToolOutput(content = missingDiffContent, toolName = "patch", isRunning = false)
|
||||
assertNotNull(missingDiffView)
|
||||
assertNull(missingDiffView?.inlineDiff)
|
||||
}
|
||||
|
||||
// ── End-to-end integration: Transcript hydration -> ToolView diff parsing ─
|
||||
|
||||
@Test
|
||||
fun endToEnd_hydratedTranscriptProducesToolViewWithInlineDiff() {
|
||||
val serverDiff = "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2"
|
||||
val sessionMsg =
|
||||
toolSessionMessage(
|
||||
content = """{"name":"patch","args":{"path":"src/Config.kt"},"result":{"success":true}}""",
|
||||
displayMetadata =
|
||||
parseJson(
|
||||
"""
|
||||
{
|
||||
"tool_result_metadata": {
|
||||
"inline_diff": "@@ -1,2 +1,2 @@\n-val x = 1\n+val x = 2"
|
||||
}
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val chatMessage = mapSingle(sessionMsg)
|
||||
assertTrue(chatMessage.content.contains("display_metadata"))
|
||||
|
||||
val toolView =
|
||||
parseToolOutput(
|
||||
content = chatMessage.content,
|
||||
toolName = "patch",
|
||||
isRunning = false,
|
||||
)
|
||||
|
||||
assertNotNull("Expected parseToolOutput to succeed for hydrated message content", toolView)
|
||||
assertEquals(serverDiff, toolView?.inlineDiff)
|
||||
assertEquals("src/Config.kt", toolView?.diffPath)
|
||||
assertEquals(1, toolView?.diffStats?.added)
|
||||
assertEquals(1, toolView?.diffStats?.removed)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user