release: promote dev to main for v2.0.0 (#1426)
Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: Lx-204 <15377891490@163.com> Co-authored-by: AlexDev_ <56083016+alexdev03@users.noreply.github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Nikhil Nair <32665919+nikhilnair31@users.noreply.github.com> Co-authored-by: marciodps <95321123+marciodps@users.noreply.github.com> Co-authored-by: ThomasCrund <39323104+ThomasCrund@users.noreply.github.com>
This commit is contained in:
315 files changed
+65173
-7909
No files matched your search
@@ -23,6 +23,7 @@ class NavigationControllerTest {
|
||||
NavigationController.backStack = null
|
||||
NavigationController.consumePendingSessionId()
|
||||
NavigationController.consumePendingNewChatNavigation()
|
||||
NavigationController.consumeChatReturnScreen()
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -30,6 +31,7 @@ class NavigationControllerTest {
|
||||
NavigationController.backStack = null
|
||||
NavigationController.consumePendingSessionId()
|
||||
NavigationController.consumePendingNewChatNavigation()
|
||||
NavigationController.consumeChatReturnScreen()
|
||||
}
|
||||
|
||||
// ── Dedup guard: navigateTo with same key ──────────────────────────────
|
||||
@@ -171,6 +173,92 @@ class NavigationControllerTest {
|
||||
assertFalse(NavigationController.consumePendingChatNavigation()?.scrollToBottom == true)
|
||||
}
|
||||
|
||||
// ── Chat return screen: back from a list-opened chat returns to that list ──
|
||||
|
||||
@Test
|
||||
fun `openChatSession from a list arms the list as the return screen`() {
|
||||
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
|
||||
|
||||
NavigationController.openChatSession("stored-session")
|
||||
|
||||
assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull())
|
||||
assertEquals(HistoryScreen, NavigationController.chatReturnScreen)
|
||||
assertEquals(HistoryScreen, NavigationController.consumeChatReturnScreen())
|
||||
assertNull("consume must disarm", NavigationController.consumeChatReturnScreen())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openChatSession from Bots arms Bots`() {
|
||||
NavigationController.backStack = NavBackStack<NavKey>(BotsScreen)
|
||||
|
||||
NavigationController.openChatSession("bot-session")
|
||||
|
||||
assertEquals(BotsScreen, NavigationController.consumeChatReturnScreen())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openChatSession from chat arms nothing`() {
|
||||
NavigationController.backStack = NavBackStack<NavKey>(ChatScreen)
|
||||
|
||||
NavigationController.openChatSession("stored-session")
|
||||
|
||||
assertNull(NavigationController.consumeChatReturnScreen())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notification open never arms a return screen`() {
|
||||
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
|
||||
|
||||
NavigationController.openChatSessionFromNotification("stored-session")
|
||||
|
||||
assertEquals(ChatScreen, NavigationController.backStack?.lastOrNull())
|
||||
assertNull(NavigationController.consumeChatReturnScreen())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `drawer navigation disarms a stale return screen`() {
|
||||
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
|
||||
NavigationController.openChatSession("stored-session")
|
||||
assertEquals(HistoryScreen, NavigationController.chatReturnScreen)
|
||||
|
||||
// User opens the drawer and picks Skills instead of pressing back.
|
||||
NavigationController.navigateTo(SkillsScreen)
|
||||
|
||||
assertNull("explicit navigation must invalidate the return", NavigationController.consumeChatReturnScreen())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `re-tapping chat in the drawer also disarms the return`() {
|
||||
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
|
||||
NavigationController.openChatSession("stored-session")
|
||||
assertEquals(HistoryScreen, NavigationController.chatReturnScreen)
|
||||
|
||||
// Same-screen dedup would skip the body — the clear must still run.
|
||||
NavigationController.navigateTo(ChatScreen)
|
||||
|
||||
assertNull(NavigationController.consumeChatReturnScreen())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `openNewChat does not arm a return screen`() {
|
||||
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
|
||||
|
||||
NavigationController.openNewChat()
|
||||
|
||||
assertNull(NavigationController.consumeChatReturnScreen())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resetTo disarms the return screen`() {
|
||||
NavigationController.backStack = NavBackStack<NavKey>(HistoryScreen)
|
||||
NavigationController.openChatSession("stored-session")
|
||||
assertEquals(HistoryScreen, NavigationController.chatReturnScreen)
|
||||
|
||||
NavigationController.resetTo(ChatScreen)
|
||||
|
||||
assertNull(NavigationController.consumeChatReturnScreen())
|
||||
}
|
||||
|
||||
// ── resetTo: atomic clear + navigate ──────────────────────────────────
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class McpServerParsingTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun `servers decode tools as null, list, or filter object`() {
|
||||
val body =
|
||||
"""{"servers":[
|
||||
{"name":"a","enabled":true,"tools":null},
|
||||
{"name":"b","enabled":true,"tools":["x","y"]},
|
||||
{"name":"c","enabled":true,"tools":{"exclude":["docs","*_radar*"]}},
|
||||
{"name":"d","enabled":true,"tools":{"include":["one","two","three"]}}
|
||||
]}"""
|
||||
val servers = json.decodeFromString<McpServersResponse>(body).servers
|
||||
assertEquals(listOf<Int?>(null, 2, null, 3), servers.map { it.toolCount })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `catalog entry decodes backend required_env and installed flags`() {
|
||||
val body =
|
||||
"""{"entries":[{"name":"gh","description":"d","source":"nous","transport":"stdio",
|
||||
"required_env":[{"name":"GH_TOKEN","prompt":"Token","required":true}],
|
||||
"installed":true,"enabled":false,"needs_install":false}],"diagnostics":[]}"""
|
||||
val entry = json.decodeFromString<McpCatalogResponse>(body).entries.single()
|
||||
assertTrue(entry.installed)
|
||||
assertEquals("GH_TOKEN", entry.env?.single()?.key)
|
||||
assertEquals("Token", entry.env?.single()?.label)
|
||||
assertNull(entry.url)
|
||||
}
|
||||
}
|
||||
@@ -622,4 +622,33 @@ class ModelSerializationTest {
|
||||
json.decodeFromString<ToggleSkillRequest>(jsonInput)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun moaConfigDecodesNullTemperaturesAndNewFields() {
|
||||
val payload =
|
||||
"""
|
||||
{"default_preset":"default","active_preset":"","privacy_filter":"",
|
||||
"presets":{"default":{"enabled":true,
|
||||
"reference_models":[{"provider":"openrouter","model":"x/y","enabled":false,"reasoning_effort":"high"}],
|
||||
"aggregator":{"provider":"openrouter","model":"a/b"},
|
||||
"reference_temperature":null,"aggregator_temperature":null,
|
||||
"reference_timeout":null,"degraded_reference_policy":"silent","fanout":"every_n:3"}},
|
||||
"reference_models":[{"provider":"openrouter","model":"x/y","enabled":true}],
|
||||
"aggregator":{"provider":"openrouter","model":"a/b"},
|
||||
"reference_temperature":null,"aggregator_temperature":null,
|
||||
"reference_timeout":null,"degraded_reference_policy":"silent","fanout":"every_n:3","enabled":true}
|
||||
""".trimIndent()
|
||||
val cfg = json.decodeFromString<MoaConfigResponse>(payload)
|
||||
assertNull(cfg.reference_temperature)
|
||||
assertNull(cfg.aggregator_temperature)
|
||||
assertEquals("every_n:3", cfg.fanout)
|
||||
val preset = cfg.presets.getValue("default")
|
||||
assertEquals("silent", preset.degraded_reference_policy)
|
||||
assertEquals(false, preset.reference_models.first().enabled)
|
||||
assertEquals("high", preset.reference_models.first().reasoning_effort)
|
||||
// Round-trip keeps the non-default knobs so a save doesn't reset them.
|
||||
val back = json.encodeToString(cfg)
|
||||
assertTrue(back.contains("every_n:3"))
|
||||
assertTrue(back.contains("silent"))
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.encodeToString
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Wire-shape pins for the destructive session ops. The backend reads the
|
||||
* profile off these request BODIES (`SessionPrune` / `BulkDeleteSessions`) —
|
||||
* the `?profile=` query rewrite never reaches them — and its prune model is
|
||||
* `older_than_days` (the old `days` key was silently ignored, leaving the
|
||||
* server to prune its own 90-day default).
|
||||
*/
|
||||
class SessionDestructiveOpsSerializationTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
@Test
|
||||
fun pruneRequest_sendsServerFieldNames() {
|
||||
val encoded = json.encodeToString(PruneRequest(olderThanDays = 7, profile = "work"))
|
||||
val body = json.parseToJsonElement(encoded).jsonObject
|
||||
|
||||
assertEquals("7", body["older_than_days"]!!.jsonPrimitive.content)
|
||||
assertEquals("work", body["profile"]!!.jsonPrimitive.content)
|
||||
// The old key must be gone: the backend ignores it.
|
||||
assertFalse(body.containsKey("days"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pruneRequest_omitsProfileWhenUnnamed() {
|
||||
val encoded = json.encodeToString(PruneRequest(olderThanDays = 30))
|
||||
val body = json.parseToJsonElement(encoded).jsonObject
|
||||
|
||||
assertEquals("30", body["older_than_days"]!!.jsonPrimitive.content)
|
||||
assertFalse(body.containsKey("profile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bulkDeleteRequest_carriesProfile() {
|
||||
val encoded = json.encodeToString(BulkDeleteRequest(ids = listOf("a", "b"), profile = "work"))
|
||||
val body = json.parseToJsonElement(encoded).jsonObject
|
||||
|
||||
assertEquals("work", body["profile"]!!.jsonPrimitive.content)
|
||||
assertEquals(listOf("a", "b"), body["ids"]!!.jsonArray.map { it.jsonPrimitive.content })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import com.m57.hermescontrol.data.model.CredentialPoolAddRequest
|
||||
import com.m57.hermescontrol.data.model.DebugShareRequest
|
||||
import com.m57.hermescontrol.data.model.HookCreateRequest
|
||||
import com.m57.hermescontrol.data.model.HookDeleteRequest
|
||||
import com.m57.hermescontrol.data.model.McpCatalogInstallRequest
|
||||
import com.m57.hermescontrol.data.model.McpServerUpdateRequest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.MediaType.Companion.toMediaType
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.converter.kotlinx.serialization.asConverterFactory
|
||||
|
||||
/**
|
||||
* Regression for #1402: `Map<String, Any>` bodies/results have no kotlinx serializer, so Retrofit failed to
|
||||
* create the converter before any request was sent. These call the real service methods so converter creation
|
||||
* (which JSON round-trip tests never exercise) is covered.
|
||||
*/
|
||||
class HermesApiServiceConverterTest {
|
||||
private lateinit var server: MockWebServer
|
||||
private lateinit var api: HermesApiService
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
server = MockWebServer().also { it.start() }
|
||||
api =
|
||||
Retrofit
|
||||
.Builder()
|
||||
.baseUrl(server.url("/"))
|
||||
.addConverterFactory(OkHttpProvider.json.asConverterFactory("application/json".toMediaType()))
|
||||
.build()
|
||||
.create(HermesApiService::class.java)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
}
|
||||
|
||||
private fun ok(body: String = """{"ok":true}""") =
|
||||
server.enqueue(MockResponse().setHeader("Content-Type", "application/json").setBody(body))
|
||||
|
||||
@Test
|
||||
fun catalogInstall_readsResponse() =
|
||||
runBlocking {
|
||||
ok("""{"ok":true,"name":"example","background":false}""")
|
||||
val r = api.installMcpCatalogEntry(McpCatalogInstallRequest(name = "example"))
|
||||
assertTrue(r.isSuccessful)
|
||||
assertEquals("example", r.body()?.name)
|
||||
assertEquals("/api/mcp/catalog/install", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateMcpServer_sendsEnvBody() =
|
||||
runBlocking {
|
||||
ok("""{"name":"s","enabled":true}""")
|
||||
api.updateMcpServer("s", McpServerUpdateRequest(env = mapOf("K" to "v")))
|
||||
val req = server.takeRequest()
|
||||
assertEquals("PUT", req.method)
|
||||
assertEquals("""{"env":{"K":"v"}}""", req.body.readUtf8())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun curatorPaused_readsResponse() =
|
||||
runBlocking {
|
||||
ok("""{"ok":true,"paused":true}""")
|
||||
assertTrue(api.setCuratorPaused(mapOf("paused" to true)).isSuccessful)
|
||||
assertEquals("""{"paused":true}""", server.takeRequest().body.readUtf8())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun credentialPool_addSendsApiKeyField_removeReadsResponse() =
|
||||
runBlocking {
|
||||
ok("""{"ok":true,"provider":"openrouter","count":1}""")
|
||||
assertTrue(api.addCredentialPoolEntry(CredentialPoolAddRequest("openrouter", "sk", "lbl")).isSuccessful)
|
||||
assertEquals(
|
||||
"""{"provider":"openrouter","api_key":"sk","label":"lbl"}""",
|
||||
server.takeRequest().body.readUtf8(),
|
||||
)
|
||||
ok("""{"ok":true,"provider":"openrouter","count":0,"cleaned":[],"hints":[]}""")
|
||||
assertTrue(api.removeCredentialPoolEntry("openrouter", 1).isSuccessful)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hooks_createSendsApprove_deleteSendsBody() =
|
||||
runBlocking {
|
||||
ok("""{"ok":true,"event":"e","command":"c","approved":true}""")
|
||||
assertTrue(api.createHook(HookCreateRequest("e", "c", timeout = 5, approve = true)).isSuccessful)
|
||||
assertEquals(
|
||||
"""{"event":"e","command":"c","timeout":5,"approve":true}""",
|
||||
server.takeRequest().body.readUtf8(),
|
||||
)
|
||||
ok()
|
||||
assertTrue(api.deleteHook(HookDeleteRequest("e", "c")).isSuccessful)
|
||||
val del = server.takeRequest()
|
||||
assertEquals("DELETE", del.method)
|
||||
assertEquals("""{"event":"e","command":"c"}""", del.body.readUtf8())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun debugShare_sendsRedact() =
|
||||
runBlocking {
|
||||
ok("""{"ok":true,"urls":{"report":"u"}}""")
|
||||
assertTrue(api.runDebugShare(DebugShareRequest(redact = false)).isSuccessful)
|
||||
assertEquals("""{"redact":false}""", server.takeRequest().body.readUtf8())
|
||||
}
|
||||
}
|
||||
+21
@@ -598,6 +598,27 @@ class HermesApiServiceMockWebServerTest {
|
||||
assertEquals(404, response.code())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getOAuthProviders_toleratesNumericExpiresAt() =
|
||||
runBlocking {
|
||||
mockServer.enqueue(
|
||||
MockResponse()
|
||||
.setResponseCode(200)
|
||||
.setBody(
|
||||
"""
|
||||
{"providers":[{"id":"codex","name":"Codex","flow":"external",
|
||||
"status":{"logged_in":true,"expires_at":1790766533634,"last_refresh":1790766000000}}]}
|
||||
""".trimIndent(),
|
||||
),
|
||||
)
|
||||
|
||||
val response = api.getOAuthProviders()
|
||||
assertTrue(response.isSuccessful)
|
||||
val status = response.body()!!.providers[0].status
|
||||
assertEquals("1790766533634", status.expiresAt)
|
||||
assertEquals("1790766000000", status.lastRefresh)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getOAuthProviders_parsesList() =
|
||||
runBlocking {
|
||||
|
||||
@@ -107,6 +107,24 @@ class AppUpdateCheckerNetworkTest {
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=2", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_rcScanAcceptsRepositoriesPaginationFormat() =
|
||||
runTest {
|
||||
enqueue(STABLE_BODY)
|
||||
val nextPage = server.url("/repositories/1270568813/releases?per_page=100&page=2")
|
||||
enqueue(RELEASES_BODY, headers = mapOf("Link" to "<$nextPage>; rel=\"next\""))
|
||||
enqueue(
|
||||
"""[{"tag_name":"v2.0.0-rc.1","prerelease":true,"assets":[{"name":"release.apk","size":5,"browser_download_url":"https://example.com/new.apk"}]}]""",
|
||||
)
|
||||
|
||||
val info = checker().fetchLatestRelease(includeReleaseCandidates = true)
|
||||
|
||||
assertEquals("v2.0.0-rc.1", info?.tagName)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases/latest", server.takeRequest().path)
|
||||
assertEquals("/repos/Hy4ri/hermes-mobile/releases?per_page=100&page=1", server.takeRequest().path)
|
||||
assertEquals("/repositories/1270568813/releases?per_page=100&page=2", server.takeRequest().path)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fetchLatestRelease_releaseCandidatesStillIgnoreAlphaAndBetaButKeepStable() =
|
||||
runTest {
|
||||
|
||||
@@ -3,6 +3,10 @@ package com.m57.hermescontrol.data.ws
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.AccountConnectorResult
|
||||
import com.m57.hermescontrol.data.model.ConnectorTool
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.unmockkAll
|
||||
@@ -28,7 +32,7 @@ class AccountConnectorRepositoryTest {
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
accountRepo { method, params ->
|
||||
calls += method to params
|
||||
operationFixture()
|
||||
}
|
||||
@@ -57,19 +61,24 @@ class AccountConnectorRepositoryTest {
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
accountRepo { method, params ->
|
||||
calls += method to params
|
||||
operationFixture()
|
||||
}
|
||||
repo.operationStatus("op-a")
|
||||
repo.operationRequest(
|
||||
WsMethods.CONNECTION_RESPOND,
|
||||
mapOf(
|
||||
"op_id" to "op-a",
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
repo.operationRespond(
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
opId = "op-a",
|
||||
result = ConnectionAnswer(settledBy = "continue"),
|
||||
),
|
||||
)
|
||||
repo.operationWake(
|
||||
ConnectorsOperationStatusParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
opId = "op-a",
|
||||
),
|
||||
)
|
||||
repo.operationRequest(WsMethods.CONNECTORS_OPERATION_WAKE, mapOf("op_id" to "op-a"))
|
||||
assertEquals(
|
||||
listOf(
|
||||
WsMethods.CONNECTORS_OPERATION_STATUS,
|
||||
@@ -86,13 +95,30 @@ class AccountConnectorRepositoryTest {
|
||||
assertEquals("op-a", params["op_id"])
|
||||
assertFalse("session_id" in params)
|
||||
}
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"op_id" to "op-a",
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
"profile" to "work",
|
||||
),
|
||||
calls[1].second,
|
||||
)
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"op_id" to "op-a",
|
||||
"profile" to "work",
|
||||
),
|
||||
calls[2].second,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `catalog accounts tools and removal use native account endpoints`() =
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
accountRepo { method, params ->
|
||||
calls += method to params
|
||||
when (method) {
|
||||
WsMethods.CONNECTORS_CATALOG -> {
|
||||
@@ -165,7 +191,7 @@ class AccountConnectorRepositoryTest {
|
||||
|
||||
@Test fun `policy keeps member revision separate from effective and respects inherited rules`() =
|
||||
runTest {
|
||||
val repo = HermesAccountConnectorRepository { _, _ -> policyFixture() }
|
||||
val repo = accountRepo { _, _ -> policyFixture() }
|
||||
val policy = (repo.policy() as AccountConnectorResult.Success).value
|
||||
assertEquals("effective-revision", policy.revision)
|
||||
assertEquals("01ARZ3NDEKTSV4RRFFQ69G5FAV", policy.member?.revision)
|
||||
@@ -180,7 +206,7 @@ class AccountConnectorRepositoryTest {
|
||||
runTest {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val repo =
|
||||
HermesAccountConnectorRepository { method, params ->
|
||||
accountRepo { method, params ->
|
||||
calls += method to params
|
||||
if (method == WsMethods.CONNECTORS_POLICY_GET) {
|
||||
policyFixture()
|
||||
@@ -208,10 +234,13 @@ class AccountConnectorRepositoryTest {
|
||||
|
||||
@Test fun `malformed authorization does not look like a successful connect`() =
|
||||
runTest {
|
||||
val repo = HermesAccountConnectorRepository { _, _ -> mapOf("status" to "initiated") }
|
||||
val repo = accountRepo { _, _ -> mapOf("status" to "initiated") }
|
||||
assertTrue(repo.connect(listOf("drive")) is AccountConnectorResult.Failure)
|
||||
}
|
||||
|
||||
private fun accountRepo(handler: suspend (String, Map<String, Any>) -> Any?) =
|
||||
HermesAccountConnectorRepository(caller = fakeCaller(handler))
|
||||
|
||||
private fun operationFixture() =
|
||||
mapOf(
|
||||
"op_id" to "op-a",
|
||||
|
||||
@@ -0,0 +1,518 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ConnectionHealthTest {
|
||||
private class Fixture(
|
||||
val scope: CoroutineScope,
|
||||
var connected: Boolean = true,
|
||||
var pingDelayMs: Long = 100L,
|
||||
var pingShouldFail: Boolean = false,
|
||||
var pendingReplyState: Boolean = false,
|
||||
var initialTimeMs: Long = 100_000L,
|
||||
) {
|
||||
var currentTimeMs: Long = initialTimeMs
|
||||
val pingCalls = mutableListOf<Long>()
|
||||
val cancelCalls = AtomicInteger(0)
|
||||
val logs = mutableListOf<String>()
|
||||
val debugLogs = mutableListOf<String>()
|
||||
|
||||
val health: ConnectionHealth =
|
||||
ConnectionHealth(
|
||||
scope = scope,
|
||||
isConnected = { connected },
|
||||
pingRequest = { timeoutMs ->
|
||||
pingCalls.add(timeoutMs)
|
||||
if (pingShouldFail) {
|
||||
throw IllegalStateException("Timed out after ${timeoutMs}ms")
|
||||
}
|
||||
if (pingDelayMs > 0) {
|
||||
delay(pingDelayMs)
|
||||
currentTimeMs += pingDelayMs
|
||||
}
|
||||
},
|
||||
cancelSocket = {
|
||||
cancelCalls.incrementAndGet()
|
||||
},
|
||||
pendingReply = { pendingReplyState },
|
||||
nowMs = { currentTimeMs },
|
||||
log = { logs.add(it) },
|
||||
debugLog = { debugLogs.add(it) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pingClockElapsedAndFlowUpdates() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingDelayMs = 120L,
|
||||
initialTimeMs = 1_000L,
|
||||
)
|
||||
try {
|
||||
assertEquals(1_000L, fixture.currentTimeMs)
|
||||
val latency = fixture.health.ping(timeoutMs = 5000L)
|
||||
assertEquals(120L, latency)
|
||||
assertEquals(120L, fixture.health.lastLatencyMs.value)
|
||||
assertEquals(1_120L, fixture.health.lastPongTimestamp)
|
||||
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pingFailureLeavesPreviousLatencyUntouched() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingDelayMs = 50L,
|
||||
initialTimeMs = 5_000L,
|
||||
)
|
||||
try {
|
||||
val firstLatency = fixture.health.ping(timeoutMs = 5000L)
|
||||
assertEquals(50L, firstLatency)
|
||||
assertEquals(50L, fixture.health.lastLatencyMs.value)
|
||||
|
||||
fixture.pingShouldFail = true
|
||||
try {
|
||||
fixture.health.ping(timeoutMs = 2000L)
|
||||
fail("Expected ping failure")
|
||||
} catch (expected: IllegalStateException) {
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Previous latency state is preserved
|
||||
assertEquals(50L, fixture.health.lastLatencyMs.value)
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopResetsLatencyToNull() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingDelayMs = 40L,
|
||||
)
|
||||
try {
|
||||
fixture.health.ping()
|
||||
assertEquals(40L, fixture.health.lastLatencyMs.value)
|
||||
|
||||
fixture.health.stop()
|
||||
assertNull(fixture.health.lastLatencyMs.value)
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onInboundFrameUpdatesPongTimestamp() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
initialTimeMs = 10_000L,
|
||||
)
|
||||
try {
|
||||
assertEquals(0L, fixture.health.lastPongTimestamp)
|
||||
|
||||
fixture.health.onInboundFrame()
|
||||
assertEquals(10_000L, fixture.health.lastPongTimestamp)
|
||||
|
||||
fixture.currentTimeMs = 25_000L
|
||||
fixture.health.onInboundFrame()
|
||||
assertEquals(25_000L, fixture.health.lastPongTimestamp)
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isHealthyStrictlyEnforcesStaleThresholdOf30000() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
connected = true,
|
||||
initialTimeMs = 50_000L,
|
||||
)
|
||||
try {
|
||||
fixture.health.onInboundFrame()
|
||||
assertEquals(50_000L, fixture.health.lastPongTimestamp)
|
||||
|
||||
// now - lastPong < 30000 is healthy
|
||||
fixture.currentTimeMs = 50_000L + 29_999L
|
||||
assertTrue(fixture.health.isHealthy)
|
||||
|
||||
// now - lastPong == 30000 is NOT healthy (< 30000 required)
|
||||
fixture.currentTimeMs = 50_000L + 30_000L
|
||||
assertFalse(fixture.health.isHealthy)
|
||||
|
||||
// now - lastPong > 30000 is NOT healthy
|
||||
fixture.currentTimeMs = 50_000L + 30_001L
|
||||
assertFalse(fixture.health.isHealthy)
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun isHealthyDisconnectedGuard() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
connected = true,
|
||||
initialTimeMs = 10_000L,
|
||||
)
|
||||
try {
|
||||
fixture.health.onInboundFrame()
|
||||
assertTrue(fixture.health.isHealthy)
|
||||
|
||||
fixture.connected = false
|
||||
assertFalse(fixture.health.isHealthy)
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun forceHealthCheckForTestWatchdogStrictThresholdCancelsSocket() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
connected = true,
|
||||
initialTimeMs = 100_000L,
|
||||
)
|
||||
try {
|
||||
fixture.health.onInboundFrame()
|
||||
|
||||
// Within stale threshold (30000ms), no cancellation
|
||||
fixture.health.forceHealthCheckForTest(30_000L)
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
|
||||
// Exceeding stale threshold (> 30000ms), cancels socket
|
||||
fixture.health.forceHealthCheckForTest(30_001L)
|
||||
assertEquals(1, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun periodicHeartbeatCancelsSocketWhenStaleThresholdExceeded() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
connected = true,
|
||||
pingShouldFail = true,
|
||||
pingDelayMs = 0L,
|
||||
initialTimeMs = 10_000L,
|
||||
)
|
||||
try {
|
||||
fixture.health.start()
|
||||
runCurrent()
|
||||
fixture.health.onInboundFrame()
|
||||
|
||||
// Advance by heartbeat interval (15000ms), still fresh
|
||||
advanceTimeBy(15_000L)
|
||||
fixture.currentTimeMs += 15_000L
|
||||
runCurrent()
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
|
||||
// Advance another heartbeat interval to 30000ms from last inbound frame
|
||||
advanceTimeBy(15_000L)
|
||||
fixture.currentTimeMs += 15_000L
|
||||
runCurrent()
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
|
||||
// Now time elapsed is > 30000ms (e.g. 30001ms)
|
||||
fixture.currentTimeMs += 1L
|
||||
advanceTimeBy(15_000L)
|
||||
fixture.currentTimeMs += 15_000L
|
||||
runCurrent()
|
||||
assertEquals(1, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setLastLatencyForTestSetsFlowDirectly() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
)
|
||||
try {
|
||||
assertNull(fixture.health.lastLatencyMs.value)
|
||||
fixture.health.setLastLatencyForTest(75L)
|
||||
assertEquals(75L, fixture.health.lastLatencyMs.value)
|
||||
fixture.health.setLastLatencyForTest(null)
|
||||
assertNull(fixture.health.lastLatencyMs.value)
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeLivenessOnTransportChangeSuccess() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingDelayMs = 25L,
|
||||
)
|
||||
try {
|
||||
var customActionCalled = false
|
||||
fixture.health.probeLivenessOnTransportChange(
|
||||
timeoutMs = 5000L,
|
||||
onFailureAction = { customActionCalled = true },
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||
assertFalse(customActionCalled)
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeLivenessOnTransportChangeFailureInvokesCustomAction() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingShouldFail = true,
|
||||
)
|
||||
try {
|
||||
var customActionCalled = false
|
||||
fixture.health.probeLivenessOnTransportChange(
|
||||
timeoutMs = 4000L,
|
||||
onFailureAction = { customActionCalled = true },
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf(4000L), fixture.pingCalls)
|
||||
assertTrue(customActionCalled)
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeLivenessOnTransportChangeFailureInvokesDefaultCancelSocket() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingShouldFail = true,
|
||||
)
|
||||
try {
|
||||
fixture.health.probeLivenessOnTransportChange(
|
||||
timeoutMs = 5000L,
|
||||
)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||
assertEquals(1, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeLivenessOnWakeIdleCancelsImmediatelyOnFailure() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingShouldFail = true,
|
||||
pendingReplyState = false,
|
||||
)
|
||||
try {
|
||||
fixture.health.probeLivenessOnWake(deferredOnce = false)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||
assertEquals(1, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeLivenessOnWakePendingReplyFirstFailureDefers3000MsAndRetriesOnce() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingShouldFail = true,
|
||||
pendingReplyState = true,
|
||||
)
|
||||
try {
|
||||
fixture.health.probeLivenessOnWake(deferredOnce = false)
|
||||
runCurrent()
|
||||
|
||||
// First probe fails, but because pendingReply=true, it does not cancel immediately.
|
||||
// It schedules a deferred retry after 3000ms.
|
||||
assertEquals(1, fixture.pingCalls.size)
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
|
||||
// Advance before 3000ms
|
||||
advanceTimeBy(2999L)
|
||||
runCurrent()
|
||||
assertEquals(1, fixture.pingCalls.size)
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
|
||||
// Advance to 3000ms - second ping is executed
|
||||
advanceTimeBy(1L)
|
||||
runCurrent()
|
||||
assertEquals(2, fixture.pingCalls.size)
|
||||
// Second probe fails, now it cancels
|
||||
assertEquals(1, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeLivenessOnWakePendingReplySecondProbeSucceedsLeavesSocketOpen() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingShouldFail = true,
|
||||
pendingReplyState = true,
|
||||
pingDelayMs = 10L,
|
||||
)
|
||||
try {
|
||||
fixture.health.probeLivenessOnWake(deferredOnce = false)
|
||||
runCurrent()
|
||||
|
||||
// First attempt failed, pending deferred retry in 3000ms
|
||||
assertEquals(1, fixture.pingCalls.size)
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
|
||||
// Before retry fires, recovery occurs: pings now succeed
|
||||
fixture.pingShouldFail = false
|
||||
|
||||
advanceTimeBy(3000L)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(2, fixture.pingCalls.size)
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancelForegroundProbeCancelsPendingProbe() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingShouldFail = true,
|
||||
pendingReplyState = true,
|
||||
)
|
||||
try {
|
||||
fixture.health.probeLivenessOnWake(deferredOnce = false)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(1, fixture.pingCalls.size)
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
|
||||
// Cancel the scheduled retry
|
||||
fixture.health.cancelForegroundProbe()
|
||||
|
||||
advanceTimeBy(5000L)
|
||||
runCurrent()
|
||||
|
||||
// No second probe should have been made, no cancellation
|
||||
assertEquals(1, fixture.pingCalls.size)
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeLivenessOnTransportChangeSuccessLogsAtDebugLevelWithoutWarning() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingDelayMs = 25L,
|
||||
)
|
||||
try {
|
||||
fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L)
|
||||
runCurrent()
|
||||
advanceTimeBy(25L)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||
assertEquals(
|
||||
listOf(
|
||||
"Network transport changed — probing WebSocket liveness",
|
||||
"WebSocket liveness probe succeeded on new transport",
|
||||
),
|
||||
fixture.debugLogs,
|
||||
)
|
||||
assertTrue(fixture.logs.isEmpty())
|
||||
assertEquals(0, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun probeLivenessOnTransportChangeFailureLogsDebugAndWarningAndCancelsSocket() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
pingShouldFail = true,
|
||||
)
|
||||
try {
|
||||
fixture.health.probeLivenessOnTransportChange(timeoutMs = 5000L)
|
||||
runCurrent()
|
||||
|
||||
assertEquals(listOf(5000L), fixture.pingCalls)
|
||||
assertEquals(
|
||||
listOf("Network transport changed — probing WebSocket liveness"),
|
||||
fixture.debugLogs,
|
||||
)
|
||||
assertEquals(
|
||||
listOf("Transport change liveness probe failed — cancelling socket"),
|
||||
fixture.logs,
|
||||
)
|
||||
assertEquals(1, fixture.cancelCalls.get())
|
||||
} finally {
|
||||
fixture.health.stop()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,11 +34,12 @@ class ConnectorRepositoryTest {
|
||||
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { method, params ->
|
||||
recordedMethod = method
|
||||
recordedParams = params
|
||||
mapOf("available" to true, "connectors" to emptyList<Map<String, Any>>())
|
||||
},
|
||||
caller =
|
||||
fakeCaller { method, params ->
|
||||
recordedMethod = method
|
||||
recordedParams = params
|
||||
mapOf("available" to true, "connectors" to emptyList<Map<String, Any>>())
|
||||
},
|
||||
)
|
||||
|
||||
val result = repo.listConnectors("sess-abc-123")
|
||||
@@ -61,17 +62,18 @@ class ConnectorRepositoryTest {
|
||||
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { method, params ->
|
||||
recordedMethod = method
|
||||
recordedParams = params
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf("connector" to "linear", "status" to "initiated"),
|
||||
),
|
||||
"summary" to mapOf("total" to 1, "initiated" to 1),
|
||||
)
|
||||
},
|
||||
caller =
|
||||
fakeCaller { method, params ->
|
||||
recordedMethod = method
|
||||
recordedParams = params
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf("connector" to "linear", "status" to "initiated"),
|
||||
),
|
||||
"summary" to mapOf("total" to 1, "initiated" to 1),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
val result =
|
||||
@@ -103,16 +105,17 @@ class ConnectorRepositoryTest {
|
||||
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, params ->
|
||||
recordedParams = params
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf("connector" to "slack", "status" to "active"),
|
||||
),
|
||||
"summary" to mapOf("total" to 1, "active" to 1),
|
||||
)
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, params ->
|
||||
recordedParams = params
|
||||
mapOf(
|
||||
"results" to
|
||||
listOf(
|
||||
mapOf("connector" to "slack", "status" to "active"),
|
||||
),
|
||||
"summary" to mapOf("total" to 1, "active" to 1),
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
repo.connect(sessionId = "sess-789", connectors = listOf("slack"))
|
||||
@@ -125,10 +128,11 @@ class ConnectorRepositoryTest {
|
||||
var rpcCalled = false
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
rpcCalled = true
|
||||
null
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
rpcCalled = true
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
val r1 = repo.listConnectors("")
|
||||
@@ -147,10 +151,11 @@ class ConnectorRepositoryTest {
|
||||
var rpcCalled = false
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
rpcCalled = true
|
||||
null
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
rpcCalled = true
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
// Blank session_id
|
||||
@@ -172,10 +177,11 @@ class ConnectorRepositoryTest {
|
||||
var rpcCalled = false
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
rpcCalled = true
|
||||
null
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
rpcCalled = true
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
// Invalid slugs: uppercase, starting with hyphen/underscore, containing spaces/symbols
|
||||
@@ -210,9 +216,10 @@ class ConnectorRepositoryTest {
|
||||
runBlocking {
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw CancellationException("coroutine cancelled")
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
throw CancellationException("coroutine cancelled")
|
||||
},
|
||||
)
|
||||
|
||||
try {
|
||||
@@ -236,13 +243,14 @@ class ConnectorRepositoryTest {
|
||||
// 4031 CONNECTORS_UNAVAILABLE
|
||||
val repo4031 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Connectors unavailable",
|
||||
code = 4031,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
|
||||
)
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Connectors unavailable",
|
||||
code = 4031,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("CONNECTORS_UNAVAILABLE"))),
|
||||
)
|
||||
},
|
||||
)
|
||||
val r4031 = repo4031.listConnectors("sess-1")
|
||||
assertTrue(r4031 is ConnectorListResult.Error)
|
||||
@@ -251,13 +259,14 @@ class ConnectorRepositoryTest {
|
||||
// 4001 NOT_OWNER
|
||||
val repo4001 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Session not owned",
|
||||
code = 4001,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
|
||||
)
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Session not owned",
|
||||
code = 4001,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("NOT_OWNER"))),
|
||||
)
|
||||
},
|
||||
)
|
||||
val r4001 = repo4001.connect("sess-1", listOf("linear"))
|
||||
assertTrue(r4001 is ConnectorConnectResult.Error)
|
||||
@@ -266,13 +275,14 @@ class ConnectorRepositoryTest {
|
||||
// 5033 UNSUPPORTED_RUNTIME
|
||||
val repo5033 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Compute host required",
|
||||
code = 5033,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
|
||||
)
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Compute host required",
|
||||
code = 5033,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("UNSUPPORTED_RUNTIME"))),
|
||||
)
|
||||
},
|
||||
)
|
||||
val r5033 = repo5033.connect("sess-1", listOf("linear"))
|
||||
assertTrue(r5033 is ConnectorConnectResult.Error)
|
||||
@@ -281,13 +291,14 @@ class ConnectorRepositoryTest {
|
||||
// 5034 INVALID_CONNECTOR_RESPONSE
|
||||
val repo5034 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Invalid connector response",
|
||||
code = 5034,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
|
||||
)
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Invalid connector response",
|
||||
code = 5034,
|
||||
data = JsonObject(mapOf("reason" to JsonPrimitive("INVALID_CONNECTOR_RESPONSE"))),
|
||||
)
|
||||
},
|
||||
)
|
||||
val r5034 = repo5034.connect("sess-1", listOf("linear"))
|
||||
assertTrue(r5034 is ConnectorConnectResult.Error)
|
||||
@@ -296,13 +307,14 @@ class ConnectorRepositoryTest {
|
||||
// -32601 Unsupported backend
|
||||
val repo32601 =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Method not found",
|
||||
code = -32601,
|
||||
data = null,
|
||||
)
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
throw HermesWsClient.HermesRpcException(
|
||||
message = "Method not found",
|
||||
code = -32601,
|
||||
data = null,
|
||||
)
|
||||
},
|
||||
)
|
||||
val r32601 = repo32601.listConnectors("sess-1")
|
||||
assertTrue(r32601 is ConnectorListResult.Error)
|
||||
@@ -314,9 +326,10 @@ class ConnectorRepositoryTest {
|
||||
runBlocking {
|
||||
val repo =
|
||||
HermesConnectorRepository(
|
||||
rpcRequest = { _, _ ->
|
||||
throw java.io.IOException("Connection reset by peer")
|
||||
},
|
||||
caller =
|
||||
fakeCaller { _, _ ->
|
||||
throw java.io.IOException("Connection reset by peer")
|
||||
},
|
||||
)
|
||||
|
||||
val rList = repo.listConnectors("sess-1")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.MessageReaction
|
||||
import io.mockk.every
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
@@ -928,6 +929,52 @@ class EventParserTest {
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseMessageReaction_returnsMessageReactionUpdated() {
|
||||
val response =
|
||||
createJsonRpcResponse(
|
||||
jsonrpc = "2.0",
|
||||
id = null,
|
||||
result = null,
|
||||
error = null,
|
||||
method = "event",
|
||||
params =
|
||||
mapOf(
|
||||
"type" to "message.reaction",
|
||||
"session_id" to "s1",
|
||||
"payload" to
|
||||
mapOf(
|
||||
"row_id" to 42,
|
||||
"role" to "user",
|
||||
"reactions" to
|
||||
listOf(
|
||||
mapOf("emoji" to "\uD83D\uDE02", "author" to "agent", "at" to 1.0),
|
||||
mapOf("emoji" to "", "author" to "user"),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
val event = EventParser.parse(response, "") as WsEvent.MessageReactionUpdated
|
||||
assertEquals(42L, event.rowId)
|
||||
assertEquals("user", event.role)
|
||||
assertEquals("s1", event.sessionId)
|
||||
assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), event.reactions)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseMessageReaction_withoutRowId_isUnknown() {
|
||||
val response =
|
||||
createJsonRpcResponse(
|
||||
jsonrpc = "2.0",
|
||||
id = null,
|
||||
result = null,
|
||||
error = null,
|
||||
method = "event",
|
||||
params = mapOf("type" to "message.reaction", "payload" to mapOf("reactions" to emptyList<Any>())),
|
||||
)
|
||||
assertTrue(EventParser.parse(response, "") is WsEvent.Unknown)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testParseUnknownType_returnsUnknownEvent() {
|
||||
val response =
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||
import com.m57.hermescontrol.data.ws.contract.TypedRpcCaller
|
||||
|
||||
/**
|
||||
* Test double for [TypedRpcCaller]: encodes the typed params exactly as the wire would, hands the
|
||||
* resulting map to [handler] together with the method name, and returns its value as a JsonElement.
|
||||
*/
|
||||
internal fun fakeCaller(handler: suspend (String, Map<String, Any>) -> Any?): TypedRpcCaller =
|
||||
object : TypedRpcCaller {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
override suspend fun <P, R> call(
|
||||
method: RpcMethod<P, R>,
|
||||
params: P,
|
||||
): R {
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(method.params, params)
|
||||
val map = (encoded.toAny() as? Map<String, Any>).orEmpty()
|
||||
return ConnectorParser.toJsonElement(handler(method.name, map)) as R
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import kotlinx.serialization.descriptors.SerialDescriptor
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
|
||||
/**
|
||||
* Vendored snapshot of the backend's published gateway contract
|
||||
* (`apps/shared/src/gateway-contract.openrpc.json` in hermes-agent), test-only.
|
||||
* Refresh with `scripts/sync-gateway-contract.sh`; provenance is in `SOURCE`.
|
||||
*/
|
||||
object GatewayContract {
|
||||
private val root: JsonObject by lazy {
|
||||
val stream =
|
||||
requireNotNull(GatewayContract::class.java.getResourceAsStream("/gateway-contract/openrpc.json")) {
|
||||
"gateway-contract/openrpc.json missing from test resources"
|
||||
}
|
||||
Json.parseToJsonElement(stream.bufferedReader().use { it.readText() }).jsonObject
|
||||
}
|
||||
|
||||
private val schemas: JsonObject get() = root["components"]!!.jsonObject["schemas"]!!.jsonObject
|
||||
|
||||
private fun names(key: String): Set<String> =
|
||||
(root[key] as JsonArray).map { it.jsonObject["name"]!!.jsonPrimitive.content }.toSet()
|
||||
|
||||
val methods: Set<String> by lazy { names("methods") }
|
||||
|
||||
/** Server→client request methods (`x-server-requests`). */
|
||||
val serverRequests: Set<String> by lazy { names("x-server-requests") }
|
||||
|
||||
private fun paramsSchema(method: String): JsonObject {
|
||||
val entry =
|
||||
(root["methods"] as JsonArray)
|
||||
.map { it.jsonObject }
|
||||
.first { it["name"]!!.jsonPrimitive.content == method }
|
||||
val ref =
|
||||
entry["params"]!!
|
||||
.jsonArray
|
||||
.first()
|
||||
.jsonObject["schema"]!!
|
||||
.jsonObject["\$ref"]!!
|
||||
.jsonPrimitive.content
|
||||
return schemas[ref.substringAfterLast('/')]!!.jsonObject
|
||||
}
|
||||
|
||||
/**
|
||||
* Keys the backend would reject for [method]: unknown keys when the params
|
||||
* schema forbids extras, plus required keys that are absent. Empty means OK.
|
||||
* Keys only; value types are not checked.
|
||||
*/
|
||||
fun paramKeyProblems(
|
||||
method: String,
|
||||
keys: Set<String>,
|
||||
): List<String> {
|
||||
if (method !in methods) return listOf("$method is not a contract method")
|
||||
val schema = paramsSchema(method)
|
||||
val properties = schema["properties"]?.jsonObject?.keys.orEmpty()
|
||||
val required = schema["required"]?.jsonArray?.map { it.jsonPrimitive.content }.orEmpty()
|
||||
val strict = schema["additionalProperties"]?.jsonPrimitive?.boolean == false
|
||||
val problems = mutableListOf<String>()
|
||||
if (strict) {
|
||||
(keys - properties).sorted().forEach {
|
||||
problems += "$method: unknown param '$it' (backend forbids extra keys; allowed: ${properties.sorted()})"
|
||||
}
|
||||
}
|
||||
required.filter { it !in keys }.forEach { problems += "$method: missing required param '$it'" }
|
||||
return problems
|
||||
}
|
||||
|
||||
/**
|
||||
* Verifies that a Kotlinx [descriptor] for [method]'s params matches the published contract.
|
||||
* Empty list means OK.
|
||||
*/
|
||||
fun paramsDescriptorProblems(
|
||||
method: String,
|
||||
descriptor: SerialDescriptor,
|
||||
): List<String> {
|
||||
if (method !in methods) return listOf("$method is not a contract method")
|
||||
val schema = paramsSchema(method)
|
||||
val propertiesObj = schema["properties"]?.jsonObject ?: JsonObject(emptyMap())
|
||||
val properties = propertiesObj.keys
|
||||
val required =
|
||||
schema["required"]
|
||||
?.jsonArray
|
||||
?.map { it.jsonPrimitive.content }
|
||||
?.toSet()
|
||||
.orEmpty()
|
||||
val problems = mutableListOf<String>()
|
||||
|
||||
val elementNames = (0 until descriptor.elementsCount).map { descriptor.getElementName(it) }.toSet()
|
||||
|
||||
for (i in 0 until descriptor.elementsCount) {
|
||||
val name = descriptor.getElementName(i)
|
||||
if (name !in properties) {
|
||||
problems +=
|
||||
"$method: unknown param '$name' (backend forbids extra keys; allowed: ${properties.sorted()})"
|
||||
}
|
||||
}
|
||||
|
||||
// No exemptions: decorate() skips injection when no profile is active, so a required `profile`
|
||||
// must be declared like any other required param.
|
||||
val missingRequired = required.filter { it !in elementNames }
|
||||
for (missing in missingRequired) {
|
||||
problems += "$method: missing required param '$missing'"
|
||||
}
|
||||
|
||||
for (i in 0 until descriptor.elementsCount) {
|
||||
val name = descriptor.getElementName(i)
|
||||
if (name in required) {
|
||||
if (descriptor.isElementOptional(i)) {
|
||||
problems += "$method: required param '$name' must not be optional"
|
||||
}
|
||||
val propSchema = propertiesObj[name]?.jsonObject
|
||||
val allowsNull = propSchema?.let { propertyAllowsNull(it) } ?: false
|
||||
if (descriptor.getElementDescriptor(i).isNullable && !allowsNull) {
|
||||
problems += "$method: required param '$name' must not be nullable unless schema allows null"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return problems
|
||||
}
|
||||
|
||||
private fun propertyAllowsNull(propertySchema: JsonObject): Boolean {
|
||||
val typeElement = propertySchema["type"]
|
||||
if (typeElement != null) {
|
||||
if (typeElement is JsonArray && typeElement.any { it.jsonPrimitive.content == "null" }) {
|
||||
return true
|
||||
}
|
||||
if (typeElement is JsonPrimitive && typeElement.content == "null") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
val anyOf = propertySchema["anyOf"]?.jsonArray
|
||||
if (anyOf != null && anyOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) {
|
||||
return true
|
||||
}
|
||||
val oneOf = propertySchema["oneOf"]?.jsonArray
|
||||
if (oneOf != null && oneOf.any { it.jsonObject["type"]?.jsonPrimitive?.content == "null" }) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WsMethods constants the backend contract does not list. Every entry needs a
|
||||
* reason; the test fails if an entry appears in the contract or is no longer a
|
||||
* WsMethods constant, so stale exceptions get removed.
|
||||
*/
|
||||
object GatewayContractAllowlist {
|
||||
private const val LEGACY = "legacy fallback for gateways before srq request frames (#1119)"
|
||||
|
||||
val entries: Map<String, String> =
|
||||
mapOf(
|
||||
"gateway.ping" to "handled inline by the WS transport itself (tui_gateway/ws.py), not a contract method",
|
||||
"clarify.respond" to LEGACY,
|
||||
"sudo.respond" to LEGACY,
|
||||
"secret.respond" to LEGACY,
|
||||
"vault.unlock.respond" to LEGACY,
|
||||
"vault.code.respond" to LEGACY,
|
||||
"vault.save_login.respond" to LEGACY,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Sends the client-owned RPCs through the real [HermesWsClient] and checks the
|
||||
* exact frame on the wire against the vendored gateway contract (#1374).
|
||||
*/
|
||||
class GatewayContractFramesTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
/** Connect, run [trigger], and return the first frame the server sees for [method]. */
|
||||
private fun captureFrame(
|
||||
method: String,
|
||||
beforeConnect: () -> Unit = {},
|
||||
trigger: () -> Unit = {},
|
||||
): JsonObject {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frame = AtomicReference<JsonObject?>(null)
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (frame.get() == null && name == method) {
|
||||
frame.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
beforeConnect()
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
trigger()
|
||||
assertTrue("No '$method' frame reached the server", captured.await(5, TimeUnit.SECONDS))
|
||||
return requireNotNull(frame.get())
|
||||
}
|
||||
|
||||
private fun assertMatchesContract(frame: JsonObject) {
|
||||
val method = (frame["method"] as JsonPrimitive).content
|
||||
val params = frame["params"] as JsonObject
|
||||
val problems = GatewayContract.paramKeyProblems(method, params.keys)
|
||||
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptSubmitFrameMatchesContract() {
|
||||
assertMatchesContract(
|
||||
captureFrame(WsMethods.PROMPT_SUBMIT) { HermesWsClient.sendMessage("s1", "hello", queued = true) },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun eventsSinceReplayFrameMatchesContract() {
|
||||
assertMatchesContract(
|
||||
captureFrame(WsMethods.SESSION_EVENTS_SINCE, beforeConnect = { HermesWsClient.setSeqWatermark("s1", 5) }),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
/** Fails CI when the app drifts from the backend's published gateway contract (#1374). */
|
||||
class GatewayContractTest {
|
||||
private val wsMethodConstants: Map<String, String> =
|
||||
WsMethods::class.java.declaredFields
|
||||
.filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java }
|
||||
.associate { it.name to it.get(null) as String }
|
||||
|
||||
@Test
|
||||
fun everyWsMethodIsInTheContractOrAllowlisted() {
|
||||
val unknown =
|
||||
wsMethodConstants.filter { (_, method) ->
|
||||
method !in GatewayContract.methods && method !in GatewayContractAllowlist.entries
|
||||
}
|
||||
assertTrue(
|
||||
"WsMethods constants missing from the gateway contract (backend removed or renamed them?): " +
|
||||
unknown.entries.joinToString { "${it.key}=\"${it.value}\"" } +
|
||||
". Fix the constant, or allowlist it with a reason in GatewayContractAllowlist.",
|
||||
unknown.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileScopedMethodsAreContractMethods() {
|
||||
val unknown = WsMethods.PROFILE_SCOPED_METHODS - GatewayContract.methods
|
||||
assertTrue("PROFILE_SCOPED_METHODS not in the contract: $unknown", unknown.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowlistEntriesHaveReasonsAndAreNotStale() {
|
||||
val sent = wsMethodConstants.values.toSet()
|
||||
GatewayContractAllowlist.entries.forEach { (method, reason) ->
|
||||
assertTrue("Allowlist entry '$method' needs a reason", reason.isNotBlank())
|
||||
assertTrue(
|
||||
"Allowlist entry '$method' is now in the contract; remove it from GatewayContractAllowlist",
|
||||
method !in GatewayContract.methods,
|
||||
)
|
||||
assertTrue(
|
||||
"Allowlist entry '$method' is no longer a WsMethods constant; remove it",
|
||||
method in sent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handledServerRequestsExistInTheContract() {
|
||||
val unknown = APP_HANDLED_SERVER_REQUESTS - GatewayContract.serverRequests
|
||||
assertTrue("Handled server requests missing from the contract: $unknown", unknown.isEmpty())
|
||||
|
||||
// Informational: unsupported requests are already answered with -32601.
|
||||
val unhandled = (GatewayContract.serverRequests - APP_HANDLED_SERVER_REQUESTS).sorted()
|
||||
println("Contract server requests the app does not handle: $unhandled")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paramKeyCheckRejectsTheRegressionsItExistsFor() {
|
||||
// #1118: the backend forbids extra keys, so since_seq broke replay with error 4000.
|
||||
val problems = GatewayContract.paramKeyProblems("session.events.since", setOf("session_id", "since_seq"))
|
||||
assertEquals(1, problems.count { "since_seq" in it })
|
||||
assertTrue(GatewayContract.paramKeyProblems("session.events.since", setOf("session_id")).isEmpty())
|
||||
assertTrue(GatewayContract.paramKeyProblems("session.nope", emptySet()).isNotEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun literalParamKeysAtCallSitesMatchTheContract() {
|
||||
CALL_SITE_PARAM_KEYS.forEach { (method, keys) ->
|
||||
val problems = GatewayContract.paramKeyProblems(method, keys)
|
||||
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedParamsMatchTheContract() {
|
||||
val methods = RpcMethods.all
|
||||
assertTrue("RpcMethods.all must not be empty", methods.isNotEmpty())
|
||||
val names = methods.map { it.name }
|
||||
assertEquals("RpcMethods.all names must be unique", names.distinct(), names)
|
||||
|
||||
val problems =
|
||||
methods.flatMap { method ->
|
||||
GatewayContract.paramsDescriptorProblems(method.name, method.params.descriptor)
|
||||
}
|
||||
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||
}
|
||||
|
||||
private companion object {
|
||||
/** Methods ChatViewModel.handleServerRequest routes; keep in sync with its `when`. */
|
||||
val APP_HANDLED_SERVER_REQUESTS =
|
||||
setOf("approval", "clarify", "sudo", "secret", "vault.code", "vault.save_login", "vault.unlock_prompt")
|
||||
|
||||
/**
|
||||
* Union of literal param keys at production call sites (the two client-owned
|
||||
* ones, prompt.submit and session.events.since, are checked against real frames
|
||||
* in [GatewayContractFramesTest]). Update together with the call site.
|
||||
*/
|
||||
val CALL_SITE_PARAM_KEYS: Map<String, Set<String>> =
|
||||
mapOf(
|
||||
// ChatViewModel, GroupChatViewModel
|
||||
"session.create" to setOf("profile", "title", "source", "hidden"),
|
||||
// ChatViewModel.resumeSession, NotificationReplyReceiver
|
||||
"session.resume" to setOf("session_id", "omit_messages"),
|
||||
// ChatViewModel, GroupChatViewModel
|
||||
"session.interrupt" to setOf("session_id"),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import com.m57.hermescontrol.data.ws.contract.ApprovalRespondParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionActiveListParams
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class HermesWsClientApprovalsTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
AuthManager.resetAuthStateForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeoutOrNull(5000) {
|
||||
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
|
||||
}
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedSendApprovalRespondPutsExactParamsOnWire() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "approval.respond") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
var onSentId: String? = null
|
||||
val returnedId =
|
||||
HermesWsClient.send(
|
||||
method = RpcMethods.APPROVAL_RESPOND,
|
||||
params =
|
||||
ApprovalRespondParams(
|
||||
sessionId = "sess-appr-wire-1",
|
||||
choice = "deny",
|
||||
all = false,
|
||||
requestId = "req-appr-wire-42",
|
||||
),
|
||||
onSent = { id -> onSentId = id },
|
||||
)
|
||||
|
||||
assertTrue(returnedId.isNotBlank())
|
||||
assertEquals(returnedId, onSentId)
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("approval.respond", (frame["method"] as? JsonPrimitive)?.content)
|
||||
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-appr-wire-1")
|
||||
put("choice", "deny")
|
||||
put("all", false)
|
||||
put("request_id", "req-appr-wire-42")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(false, (params?.get("all") as? JsonPrimitive)?.booleanOrNull)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallSessionActiveListReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("active_count", 2)
|
||||
put(
|
||||
"sessions",
|
||||
buildJsonObject {
|
||||
put("primary", "sess-alpha")
|
||||
put("secondary", "sess-beta")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.active_list") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_ACTIVE_LIST,
|
||||
SessionActiveListParams(),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
assertEquals("session.active_list", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
// An empty params object is omitted on wire or empty
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertTrue(
|
||||
"session.active_list with null currentSessionId carries no params",
|
||||
params == null || params.isEmpty(),
|
||||
)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,431 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import com.m57.hermescontrol.data.ws.contract.EmptyParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SubscriptionChangeParams
|
||||
import com.m57.hermescontrol.data.ws.contract.VaultLockParams
|
||||
import com.m57.hermescontrol.data.ws.contract.VaultUnlockParams
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class HermesWsClientBillingVaultTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
AuthManager.resetAuthStateForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeoutOrNull(5000) {
|
||||
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
|
||||
}
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallSubscriptionChangeEncodesOnlyNonNullFieldsAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("status", "ok")
|
||||
put("subscription_id", "sub-12345")
|
||||
put("scheduled_cancellation", false)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "subscription.change") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SUBSCRIPTION_CHANGE,
|
||||
SubscriptionChangeParams(
|
||||
subscriptionTypeId = null,
|
||||
cancel = false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("subscription.change", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("cancel", false)
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(false, (params?.get("cancel") as? JsonPrimitive)?.booleanOrNull)
|
||||
assertFalse(params?.containsKey("subscription_type_id") ?: true)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallVaultUnlockPutsNameAndPasswordOnWireAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("unlocked", true)
|
||||
put("expires_in", 3600)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "vault.unlock") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.VAULT_UNLOCK,
|
||||
VaultUnlockParams(
|
||||
name = "op-work",
|
||||
password = "super-secret-pass",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertEquals("vault.unlock", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("name", "op-work")
|
||||
put("password", "super-secret-pass")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallVaultLockWithNamePutsNameOnWireAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("locked", true)
|
||||
put("vault", "op-work")
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "vault.lock") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.VAULT_LOCK,
|
||||
VaultLockParams(name = "op-work"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("name", "op-work")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallVaultLockWithoutNameLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("locked_all", true)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "vault.lock") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.VAULT_LOCK,
|
||||
VaultLockParams(name = null),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertEquals("vault.lock", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertTrue(
|
||||
"vault.lock with null name carries no params",
|
||||
params == null || params.isEmpty(),
|
||||
)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallEmptyParamsMethodLeavesParamsAbsentOrEmptyAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("status", "active")
|
||||
put("tier", "standard")
|
||||
put("valid_until", "2026-12-31T23:59:59Z")
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "subscription.state") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SUBSCRIPTION_STATE,
|
||||
EmptyParams,
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertEquals("subscription.state", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertTrue(
|
||||
"subscription.state with EmptyParams carries no params",
|
||||
params == null || params.isEmpty(),
|
||||
)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
}
|
||||
+532
@@ -0,0 +1,532 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
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.Before
|
||||
import org.junit.Test
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
/**
|
||||
* Characterization test suite capturing existing baseline behaviors and edge cases
|
||||
* of [HermesWsClient] before extracting components in issue #1376.
|
||||
*/
|
||||
class HermesWsClientCharacterizationTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
private val serverSockets = Collections.synchronizedList(mutableListOf<WebSocket>())
|
||||
private lateinit var serverClosed: CountDownLatch
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
serverSockets.clear()
|
||||
serverClosed = CountDownLatch(1)
|
||||
mockkStatic(Log::class)
|
||||
every { Log.isLoggable(any(), any()) } returns false
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.connectedForTest.set(false)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED
|
||||
HermesWsClient.messageQueueForTest.clear()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
HermesWsClient.acceptQueuedMessagesForTest.set(true)
|
||||
HermesWsClient.clearCredentialWarning()
|
||||
HermesWsClient.clearSeqWatermarks()
|
||||
HermesWsClient.setLastLatencyForTest(null)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
HermesWsClient.rejectAllPending()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(3000) {
|
||||
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
|
||||
}
|
||||
}
|
||||
try {
|
||||
if (serverSockets.isNotEmpty()) {
|
||||
assertTrue("Server endpoint must finish closing", serverClosed.await(3, TimeUnit.SECONDS))
|
||||
}
|
||||
mockWebServer.shutdown()
|
||||
} finally {
|
||||
unmockkAll()
|
||||
}
|
||||
}
|
||||
|
||||
private fun connectClientToServer(onServerOpen: ((WebSocket) -> Unit)? = null): WebSocket {
|
||||
val serverOpenLatch = CountDownLatch(1)
|
||||
var serverWs: WebSocket? = null
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
serverClosed.countDown()
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: okhttp3.Response?,
|
||||
) {
|
||||
serverClosed.countDown()
|
||||
}
|
||||
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: okhttp3.Response,
|
||||
) {
|
||||
serverSockets.add(webSocket)
|
||||
serverWs = webSocket
|
||||
onServerOpen?.invoke(webSocket)
|
||||
serverOpenLatch.countDown()
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) {
|
||||
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
|
||||
}
|
||||
}
|
||||
assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS))
|
||||
return requireNotNull(serverWs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun credentialWarning_updatedByGatewayReadyAndSessionInfo_retainedOnBlank_clearedExplicitly() {
|
||||
assertNull(HermesWsClient.credentialWarning.value)
|
||||
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
val dummySocket =
|
||||
mockk<WebSocket>(relaxed = true) {
|
||||
every { send(any<String>()) } returns true
|
||||
}
|
||||
HermesWsClient.webSocketForTest = dummySocket
|
||||
val generation = HermesWsClient.connectionGenerationForTest.get()
|
||||
val listener = HermesWsClient.createListenerForTest(generation)
|
||||
|
||||
// 1. GatewayReady with non-blank credential_warning updates flow
|
||||
val gatewayReadyFrame =
|
||||
"""{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":"Warning: Auth key expired"}}}"""
|
||||
listener.onMessage(dummySocket, gatewayReadyFrame)
|
||||
|
||||
runBlocking {
|
||||
withTimeout(3000) {
|
||||
HermesWsClient.credentialWarning.first { it == "Warning: Auth key expired" }
|
||||
}
|
||||
}
|
||||
assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value)
|
||||
|
||||
// 2. Blank or absent credential_warning does NOT clear the existing value.
|
||||
// Deliver follow-up gateway.ready (absent) after blank and check warning is retained,
|
||||
// then use session.info barrier to prove processing pipeline is drained.
|
||||
val blankFrame =
|
||||
"""{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"credential_warning":""}}}"""
|
||||
listener.onMessage(dummySocket, blankFrame)
|
||||
assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value)
|
||||
|
||||
val absentFrame =
|
||||
"""{"jsonrpc":"2.0","method":"event","params":{"type":"gateway.ready","payload":{"version":"1.0"}}}"""
|
||||
listener.onMessage(dummySocket, absentFrame)
|
||||
assertEquals("Warning: Auth key expired", HermesWsClient.credentialWarning.value)
|
||||
|
||||
// Barrier: a distinct non-blank session.info proves all preceding frames were processed in order
|
||||
val barrierFrame =
|
||||
"""{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s-barrier","payload":{"credential_warning":"Barrier reached"}}}"""
|
||||
listener.onMessage(dummySocket, barrierFrame)
|
||||
runBlocking {
|
||||
withTimeout(3000) {
|
||||
HermesWsClient.credentialWarning.first { it == "Barrier reached" }
|
||||
}
|
||||
}
|
||||
assertEquals("Barrier reached", HermesWsClient.credentialWarning.value)
|
||||
|
||||
// 3. SessionInfo with non-blank updates warning
|
||||
val sessionInfoFrame =
|
||||
"""{"jsonrpc":"2.0","method":"event","params":{"type":"session.info","session_id":"s1","payload":{"credential_warning":"Session warning"}}}"""
|
||||
listener.onMessage(dummySocket, sessionInfoFrame)
|
||||
|
||||
runBlocking {
|
||||
withTimeout(3000) {
|
||||
HermesWsClient.credentialWarning.first { it == "Session warning" }
|
||||
}
|
||||
}
|
||||
assertEquals("Session warning", HermesWsClient.credentialWarning.value)
|
||||
|
||||
// 4. Explicit clear resets to null
|
||||
HermesWsClient.clearCredentialWarning()
|
||||
assertNull(HermesWsClient.credentialWarning.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disconnect_resetsLastLatencyMsToNull() {
|
||||
HermesWsClient.setLastLatencyForTest(42L)
|
||||
assertEquals(42L, HermesWsClient.lastLatencyMs.value)
|
||||
|
||||
HermesWsClient.disconnect()
|
||||
assertNull(HermesWsClient.lastLatencyMs.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcTimeout_emitsHermesRpcExceptionCodeMinusOneExactMessage() {
|
||||
val serverWs = connectClientToServer()
|
||||
assertNotNull(serverWs)
|
||||
|
||||
val shortTimeoutMs = 150L
|
||||
val deferred = HermesWsClient.request("custom.test_method", emptyMap(), timeoutMs = shortTimeoutMs)
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
withTimeout(3000) {
|
||||
deferred.await()
|
||||
}
|
||||
}
|
||||
fail("Expected deferred to fail with timeout exception")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals(-1, e.code)
|
||||
assertEquals("Request timed out: custom.test_method", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectAllPending_failsRequestsWithCustomException_cancelsTimeout_clearsPending() {
|
||||
connectClientToServer()
|
||||
|
||||
val deferred = HermesWsClient.request("custom.hold", emptyMap(), timeoutMs = 10_000L)
|
||||
val pendingIds = HermesWsClient.pendingCallIdsForTest()
|
||||
assertEquals(1, pendingIds.size)
|
||||
val reqId = pendingIds.first()
|
||||
|
||||
val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId)
|
||||
assertNotNull("Timeout job should be scheduled", timeoutJob)
|
||||
assertTrue("Timeout job should be active", timeoutJob?.isActive == true)
|
||||
|
||||
val customException = HermesWsClient.HermesRpcException("Custom forced rejection", code = -99)
|
||||
HermesWsClient.rejectAllPending(customException)
|
||||
|
||||
assertTrue(
|
||||
"Pending map should be empty after rejectAllPending",
|
||||
HermesWsClient.pendingCallIdsForTest().isEmpty(),
|
||||
)
|
||||
assertTrue("Timeout job should be cancelled", timeoutJob?.isCancelled == true)
|
||||
assertTrue("Deferred should be completed exceptionally", deferred.isCompleted)
|
||||
|
||||
try {
|
||||
runBlocking { deferred.await() }
|
||||
fail("Expected custom exception")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals(-99, e.code)
|
||||
assertEquals("Custom forced rejection", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconnectForNetworkChange_rejectsPendingCallsImmediately() {
|
||||
connectClientToServer()
|
||||
|
||||
val deferred = HermesWsClient.request("custom.in_flight", emptyMap(), timeoutMs = 30_000L)
|
||||
assertEquals(1, HermesWsClient.pendingCallIdsForTest().size)
|
||||
|
||||
// Network change triggers immediate rejectAllPending
|
||||
HermesWsClient.reconnectForNetworkChange(networkAvailable = false)
|
||||
|
||||
assertTrue("Pending map should be empty after network change", HermesWsClient.pendingCallIdsForTest().isEmpty())
|
||||
assertTrue("Deferred must be completed", deferred.isCompleted)
|
||||
|
||||
try {
|
||||
runBlocking { deferred.await() }
|
||||
fail("Expected pending call to be rejected on network change")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals("Connection lost — request cancelled", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disconnectAndListenerClosed_doNotRejectAllPending_deferredRemainsPendingUntilTimeoutOrReject() {
|
||||
val serverWs = connectClientToServer()
|
||||
|
||||
val shortTimeoutMs = 300L
|
||||
val deferred = HermesWsClient.request("custom.deferred_call", emptyMap(), timeoutMs = shortTimeoutMs)
|
||||
val reqId = HermesWsClient.pendingCallIdsForTest().first()
|
||||
|
||||
// Drive onClosed on the CURRENT active generation before disconnect.
|
||||
// In onClosed: isCurrent() is true (intentionalClose is still false, generation matches).
|
||||
// This exercises the active listener onClosed branch.
|
||||
val activeGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||
val currentListener = HermesWsClient.createListenerForTest(activeGeneration)
|
||||
val activeClientSocket = HermesWsClient.webSocketForTest ?: serverWs
|
||||
|
||||
currentListener.onClosed(activeClientSocket, 1000, "Normal closure")
|
||||
activeClientSocket.cancel()
|
||||
|
||||
// Deferred remains pending after active onClosed
|
||||
assertFalse("Deferred should still remain pending after onClosed", deferred.isCompleted)
|
||||
assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId))
|
||||
|
||||
// Calling disconnect() does NOT invoke rejectAllPending() either
|
||||
HermesWsClient.disconnect()
|
||||
|
||||
assertFalse("Deferred should NOT be completed immediately by disconnect() alone", deferred.isCompleted)
|
||||
assertTrue("Request ID should still be in pendingCalls", HermesWsClient.pendingCallIdsForTest().contains(reqId))
|
||||
|
||||
// Eventually, the per-request timeout triggers and resolves it
|
||||
try {
|
||||
runBlocking {
|
||||
withTimeout(2000) {
|
||||
deferred.await()
|
||||
}
|
||||
}
|
||||
fail("Deferred should fail with timeout")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals(-1, e.code)
|
||||
assertEquals("Request timed out: custom.deferred_call", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun combinedExternalAndBackgroundLeases_releaseOneKeepsOther_releaseBothDisconnectsWhenBackgrounded() {
|
||||
connectClientToServer()
|
||||
assertTrue(HermesWsClient.isConnected)
|
||||
|
||||
// Acquire both leases
|
||||
HermesWsClient.acquireExternalActivityConnectionLease()
|
||||
HermesWsClient.acquireBackgroundConnectionLease()
|
||||
assertTrue(HermesWsClient.hasBackgroundConnectionLease())
|
||||
|
||||
// Move to background - should NOT disconnect because leases are active
|
||||
HermesWsClient.setAppForeground(false)
|
||||
assertTrue("Should stay connected while leases are active", HermesWsClient.isConnected)
|
||||
|
||||
// Release external lease - background lease still holds connection open
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
assertTrue("Should stay connected with background lease active", HermesWsClient.isConnected)
|
||||
|
||||
// Release background lease - both leases released, app backgrounded, idle -> disconnects
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
assertFalse("Should disconnect when all leases released in background", HermesWsClient.isConnected)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun delayedReplayAndLiveRace_incomingSeqBufferedBeforeReplayResponseAndDeduped() {
|
||||
val sessionId = "session-characterization-race"
|
||||
val serverOpenLatch = CountDownLatch(1)
|
||||
val replayReceivedLatch = CountDownLatch(1)
|
||||
val allowReplayResponseLatch = CountDownLatch(1)
|
||||
val capturedReplayId = AtomicReference<String>()
|
||||
var serverSocketRef: WebSocket? = null
|
||||
|
||||
// Enqueue MockResponse with WebSocketListener on MockWebServer
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onClosing(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
webSocket.close(code, reason)
|
||||
}
|
||||
|
||||
override fun onClosed(
|
||||
webSocket: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
) {
|
||||
serverClosed.countDown()
|
||||
}
|
||||
|
||||
override fun onFailure(
|
||||
webSocket: WebSocket,
|
||||
t: Throwable,
|
||||
response: okhttp3.Response?,
|
||||
) {
|
||||
serverClosed.countDown()
|
||||
}
|
||||
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: okhttp3.Response,
|
||||
) {
|
||||
serverSockets.add(webSocket)
|
||||
serverSocketRef = webSocket
|
||||
serverOpenLatch.countDown()
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
if (text.contains(WsMethods.SESSION_EVENTS_SINCE)) {
|
||||
val request = OkHttpProvider.json.parseToJsonElement(text) as JsonObject
|
||||
val id = (request["id"] as JsonPrimitive).content
|
||||
capturedReplayId.set(id)
|
||||
replayReceivedLatch.countDown()
|
||||
|
||||
if (allowReplayResponseLatch.await(5, TimeUnit.SECONDS)) {
|
||||
val rpcId = requireNotNull(capturedReplayId.get())
|
||||
val idJson = JsonPrimitive(rpcId).toString()
|
||||
val replayResponse =
|
||||
"""{"jsonrpc":"2.0","id":$idJson,"result":{"epoch":"epoch-1","latest_seq":12,"events":[{"type":"message.complete","session_id":"$sessionId","seq":11,"payload":{"text":"Replayed message 11"}},{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Replayed message 12"}}]}}"""
|
||||
webSocket.send(replayResponse)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
// Set watermark at seq 10 and epoch-1 prior to connect
|
||||
HermesWsClient.setSeqWatermark(sessionId, 10)
|
||||
HermesWsClient.setReplayEpochForTest("epoch-1")
|
||||
|
||||
// Prepare subscription with onSubscription latch before stimulus
|
||||
val collectedEvents = Collections.synchronizedList(mutableListOf<WsEvent>())
|
||||
val subscriptionLatch = CountDownLatch(1)
|
||||
val collectLatch = CountDownLatch(2)
|
||||
val liveProcessedLatch = CountDownLatch(1)
|
||||
|
||||
val collectorJob =
|
||||
kotlinx.coroutines.CoroutineScope(kotlinx.coroutines.Dispatchers.IO).launch {
|
||||
HermesWsClient.events
|
||||
.onSubscription {
|
||||
subscriptionLatch.countDown()
|
||||
}.collect { event ->
|
||||
if (event is WsEvent.SessionInfo && event.data?.get("barrier") == "live-processed") {
|
||||
liveProcessedLatch.countDown()
|
||||
}
|
||||
if (event is WsEvent.MessageComplete && event.sessionId == sessionId) {
|
||||
collectedEvents.add(event)
|
||||
collectLatch.countDown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
assertTrue("Collector subscription should be established", subscriptionLatch.await(3, TimeUnit.SECONDS))
|
||||
|
||||
// Connect client - real onOpen automatically arms replay
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) {
|
||||
HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED }
|
||||
}
|
||||
}
|
||||
assertTrue("Server WebSocket should open", serverOpenLatch.await(5, TimeUnit.SECONDS))
|
||||
val serverSocket = requireNotNull(serverSocketRef)
|
||||
|
||||
// Wait until the server receives session.events.since request
|
||||
assertTrue("Server should receive replay request", replayReceivedLatch.await(5, TimeUnit.SECONDS))
|
||||
assertTrue("Replay should be in flight", HermesWsClient.isReplayInFlight())
|
||||
|
||||
// 1. Live frame arrived while replay is in flight (seq 12)
|
||||
val liveFrame =
|
||||
"""{"jsonrpc":"2.0","method":"event","params":{"type":"message.complete","session_id":"$sessionId","seq":12,"payload":{"text":"Live message 12"}}}"""
|
||||
serverSocket.send(liveFrame)
|
||||
|
||||
// A following unsequenced frame proves the live notification reached the client
|
||||
// before the replay response is released; unchanged watermark alone is not a barrier.
|
||||
serverSocket.send(
|
||||
"""{"method":"event","params":{"type":"session.info","session_id":"$sessionId","payload":{"barrier":"live-processed"}}}""",
|
||||
)
|
||||
assertTrue(
|
||||
"Live frame must be processed before replay completes",
|
||||
liveProcessedLatch.await(3, TimeUnit.SECONDS),
|
||||
)
|
||||
assertEquals(10, HermesWsClient.getSeqWatermarks()[sessionId])
|
||||
assertEquals("Live frame should be held in replayHold while replay is in flight", 0, collectedEvents.size)
|
||||
|
||||
// 2. Unblock replay response from server
|
||||
allowReplayResponseLatch.countDown()
|
||||
|
||||
// Wait for replay to deliver replayed events and drain held live events
|
||||
assertTrue("Should collect replayed and unheld events", collectLatch.await(5, TimeUnit.SECONDS))
|
||||
|
||||
// Watermark should advance to 12
|
||||
assertEquals(12, HermesWsClient.getSeqWatermarks()[sessionId])
|
||||
|
||||
// Deduplication: held live frame seq 12 deduped against replay seq 12
|
||||
assertEquals(2, collectedEvents.size)
|
||||
assertEquals("Replayed message 11", (collectedEvents[0] as WsEvent.MessageComplete).text)
|
||||
assertEquals("Replayed message 12", (collectedEvents[1] as WsEvent.MessageComplete).text)
|
||||
} finally {
|
||||
allowReplayResponseLatch.countDown()
|
||||
collectorJob.cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
+562
@@ -0,0 +1,562 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
|
||||
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class HermesWsClientConnectionOperationTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
AuthManager.resetAuthStateForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeoutOrNull(5000) {
|
||||
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
|
||||
}
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallConnectionRespondWithApprovedEnvAndAccountOwnerEncodesFrameAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("status", "ok")
|
||||
put("settled", true)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "connection.respond") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.CONNECTION_RESPOND,
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
opId = "op-approved-123",
|
||||
result =
|
||||
ConnectionAnswer(
|
||||
targets =
|
||||
listOf(
|
||||
ConnectionAnswerTarget(
|
||||
name = "github",
|
||||
status = "approved",
|
||||
detail = "OAuth granted",
|
||||
env = mapOf("TOKEN" to "dummy_secret_token_abc"),
|
||||
),
|
||||
),
|
||||
settledBy = null,
|
||||
),
|
||||
profile = "prod",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-approved-123")
|
||||
put(
|
||||
"result",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"targets",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("name", "github")
|
||||
put("status", "approved")
|
||||
put("detail", "OAuth granted")
|
||||
put(
|
||||
"env",
|
||||
buildJsonObject {
|
||||
put("TOKEN", "dummy_secret_token_abc")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
put("profile", "prod")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallConnectionRespondWithSkippedOmittedEnvAndSessionOwnerEncodesFrameAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("status", "ok")
|
||||
put("settled", false)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "connection.respond") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.CONNECTION_RESPOND,
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.session("sess-skip-456"),
|
||||
opId = "op-skip-456",
|
||||
result =
|
||||
ConnectionAnswer(
|
||||
targets =
|
||||
listOf(
|
||||
ConnectionAnswerTarget(
|
||||
name = "slack",
|
||||
status = "skipped",
|
||||
detail = null,
|
||||
env = null,
|
||||
),
|
||||
),
|
||||
settledBy = null,
|
||||
),
|
||||
profile = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "sess-skip-456")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-skip-456")
|
||||
put(
|
||||
"result",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"targets",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("name", "slack")
|
||||
put("status", "skipped")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertFalse(params?.containsKey("profile") ?: true)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallConnectionRespondWithContinueAndOmittedTargetsEncodesFrameAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("status", "ok")
|
||||
put("settled", true)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "connection.respond") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.CONNECTION_RESPOND,
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.session("sess-continue-789"),
|
||||
opId = "op-continue-789",
|
||||
result =
|
||||
ConnectionAnswer(
|
||||
targets = null,
|
||||
settledBy = "continue",
|
||||
),
|
||||
profile = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("connection.respond", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "sess-continue-789")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-continue-789")
|
||||
put(
|
||||
"result",
|
||||
buildJsonObject {
|
||||
put("settled_by", "continue")
|
||||
},
|
||||
)
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertFalse(params?.containsKey("profile") ?: true)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallConnectorsOperationWakeEncodesOwnerSessionOpIdAndProfileAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("op_id", "op-wake-777")
|
||||
put("status", "running")
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "connectors.operation.wake") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.CONNECTORS_OPERATION_WAKE,
|
||||
ConnectorsOperationStatusParams(
|
||||
owner = ConnectorOwner.session("sess-wake-99"),
|
||||
opId = "op-wake-777",
|
||||
profile = "custom-profile",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "sess-wake-99")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-wake-777")
|
||||
put("profile", "custom-profile")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hermesRpcCallerCallDelegatesForWakeWithAccountOwnerAndNullProfile() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("op_id", "op-wake-888")
|
||||
put("status", "done")
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "connectors.operation.wake") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesRpcCaller.call(
|
||||
RpcMethods.CONNECTORS_OPERATION_WAKE,
|
||||
ConnectorsOperationStatusParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
opId = "op-wake-888",
|
||||
profile = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertEquals("connectors.operation.wake", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-wake-888")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertFalse(params?.containsKey("profile") ?: true)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsConnectParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsListParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsPolicySetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.HermesRpcCaller
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.booleanOrNull
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class HermesWsClientConnectorsTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
AuthManager.resetAuthStateForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeoutOrNull(5000) {
|
||||
HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED }
|
||||
}
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallConnectorsConnectEncodesOwnerAccountReconnectTrueAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("op_id", "op-connect-123")
|
||||
put("status", "running")
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "connectors.connect") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.CONNECTORS_CONNECT,
|
||||
ConnectorsConnectParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
connectors = listOf("slack", "github"),
|
||||
reconnect = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("connectors.connect", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
},
|
||||
)
|
||||
put(
|
||||
"connectors",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("slack"))
|
||||
add(JsonPrimitive("github"))
|
||||
},
|
||||
)
|
||||
put("reconnect", true)
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(true, (params?.get("reconnect") as? JsonPrimitive)?.booleanOrNull)
|
||||
assertFalse(params?.containsKey("profile") ?: true)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallConnectorsPolicySetEncodesNestedChangeWithArrayAndReturnsRawServerJsonUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("revision", 5)
|
||||
put("applied", true)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "connectors.policy.set") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val nestedChange =
|
||||
buildJsonObject {
|
||||
put("policy", "whitelist")
|
||||
put(
|
||||
"allowed_connectors",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("notion"))
|
||||
add(JsonPrimitive("linear"))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"rules",
|
||||
buildJsonObject {
|
||||
put("require_auth", true)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.CONNECTORS_POLICY_SET,
|
||||
ConnectorsPolicySetParams(
|
||||
change = nestedChange,
|
||||
expectedRevision = "4",
|
||||
profile = "team-profile",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertEquals("connectors.policy.set", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("change", nestedChange)
|
||||
put("expected_revision", "4")
|
||||
put("profile", "team-profile")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(nestedChange, params?.get("change"))
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hermesRpcCallerCallDelegatesToHermesWsClientCallAndEncodesFrameParamsUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"connectors",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("id", "c1")
|
||||
put("status", "connected")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "connectors.list") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":""" + serverResultJson + """}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesRpcCaller.call(
|
||||
RpcMethods.CONNECTORS_LIST,
|
||||
ConnectorsListParams(
|
||||
owner = ConnectorOwner.session("s1"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertEquals("connectors.list", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "s1")
|
||||
},
|
||||
)
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
}
|
||||
+14
-65
@@ -11,7 +11,6 @@ import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.onSubscription
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -30,11 +29,8 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Collections
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
class HermesWsClientLoggingSecurityTest {
|
||||
private val capturedDebugLogs = Collections.synchronizedList(mutableListOf<String>())
|
||||
@@ -87,17 +83,9 @@ class HermesWsClientLoggingSecurityTest {
|
||||
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as AtomicBoolean).set(false)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as AtomicInteger).set(1)
|
||||
HermesWsClient.connectedForTest.set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
|
||||
HermesWsClient.rejectAllPending()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
@@ -113,51 +101,20 @@ class HermesWsClientLoggingSecurityTest {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun createWsListener(generation: Int? = null): WebSocketListener {
|
||||
val targetGen =
|
||||
generation ?: run {
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as AtomicInteger).get()
|
||||
}
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
return constructor.newInstance(targetGen) as WebSocketListener
|
||||
}
|
||||
private fun createWsListener(generation: Int? = null): WebSocketListener =
|
||||
HermesWsClient.createListenerForTest(generation ?: HermesWsClient.connectionGenerationForTest.get())
|
||||
|
||||
private fun attachMockSocket(): WebSocket {
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns true
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as AtomicBoolean).set(false)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
return socket
|
||||
}
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
private fun getPendingCalls(): ConcurrentHashMap<String, Any> {
|
||||
val field = HermesWsClient::class.java.getDeclaredField("pendingCalls")
|
||||
field.isAccessible = true
|
||||
return field.get(HermesWsClient) as ConcurrentHashMap<String, Any>
|
||||
}
|
||||
|
||||
private fun getTimeoutJob(pendingCall: Any): Job? {
|
||||
val field = pendingCall.javaClass.getDeclaredField("timeoutJob")
|
||||
field.isAccessible = true
|
||||
return field.get(pendingCall) as? Job
|
||||
}
|
||||
|
||||
// ── Formatter unit tests ──────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
@@ -255,8 +212,7 @@ class HermesWsClientLoggingSecurityTest {
|
||||
val listener = createWsListener()
|
||||
|
||||
val deferred = HermesWsClient.request("connectors.connect", mapOf("connector" to "synthetic_provider"))
|
||||
val pendingCalls = getPendingCalls()
|
||||
val reqId = pendingCalls.keys.firstOrNull()
|
||||
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
|
||||
assertNotNull("Pending call ID must exist", reqId)
|
||||
|
||||
val sensitiveAuthUrl =
|
||||
@@ -332,8 +288,7 @@ class HermesWsClientLoggingSecurityTest {
|
||||
assertTrue("Event collector must subscribe", subscribedLatch.await(3, TimeUnit.SECONDS))
|
||||
|
||||
val deferred = HermesWsClient.request("session.steer", mapOf("session_id" to "sess-ownership-4001"))
|
||||
val pendingCalls = getPendingCalls()
|
||||
val reqId = pendingCalls.keys.firstOrNull()
|
||||
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
|
||||
assertNotNull("Pending call ID must exist", reqId)
|
||||
|
||||
val errorJson =
|
||||
@@ -434,15 +389,9 @@ class HermesWsClientLoggingSecurityTest {
|
||||
fun `cancelling pending deferred cleans pending requests and cancels timeout job`() {
|
||||
attachMockSocket()
|
||||
val deferred = HermesWsClient.request("test.cancellation.method", mapOf("synthetic" to "fixture"))
|
||||
val pendingCalls = getPendingCalls()
|
||||
val entry =
|
||||
pendingCalls.entries.firstOrNull {
|
||||
it.value.javaClass.name
|
||||
.contains("PendingCall")
|
||||
}
|
||||
assertNotNull("Pending call must be registered in pendingCalls map", entry)
|
||||
val reqId = entry!!.key
|
||||
val timeoutJob = getTimeoutJob(entry.value)
|
||||
val reqId = HermesWsClient.pendingCallIdsForTest().firstOrNull()
|
||||
assertNotNull("Pending call must be registered in pendingCalls map", reqId)
|
||||
val timeoutJob = HermesWsClient.pendingCallTimeoutForTest(reqId!!)
|
||||
assertNotNull("Timeout job must be initialized", timeoutJob)
|
||||
assertTrue("Timeout job must be active while request is pending", timeoutJob!!.isActive)
|
||||
|
||||
@@ -451,7 +400,7 @@ class HermesWsClientLoggingSecurityTest {
|
||||
assertTrue("Deferred must be cancelled", deferred.isCancelled)
|
||||
assertFalse(
|
||||
"Pending call must be removed from pendingCalls map on cancellation",
|
||||
pendingCalls.containsKey(reqId),
|
||||
HermesWsClient.pendingCallIdsForTest().contains(reqId),
|
||||
)
|
||||
assertTrue("Timeout job must be cancelled on deferred cancellation", timeoutJob.isCancelled)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionCorrectionParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionInterruptParams
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class HermesWsClientSessionControlTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
AuthManager.resetAuthStateForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendRedirectPutsExpectedFrameAndInvokesOnSent() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.redirect") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
var onSentId: String? = null
|
||||
val returnedId =
|
||||
HermesWsClient.sendRedirect(
|
||||
sessionId = "sess-red-1",
|
||||
text = "change topic to testing",
|
||||
onSent = { id -> onSentId = id },
|
||||
)
|
||||
|
||||
assertTrue(returnedId.isNotBlank())
|
||||
assertEquals(returnedId, onSentId)
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("session.redirect", (frame["method"] as? JsonPrimitive)?.content)
|
||||
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-red-1")
|
||||
put("text", "change topic to testing")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(setOf("session_id", "text"), params!!.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedSendSessionInterruptPutsExpectedFrameAndInvokesOnSent() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.interrupt") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
var onSentId: String? = null
|
||||
val returnedId =
|
||||
HermesWsClient.send(
|
||||
method = RpcMethods.SESSION_INTERRUPT,
|
||||
params = SessionInterruptParams(sessionId = "s1"),
|
||||
onSent = { id -> onSentId = id },
|
||||
)
|
||||
|
||||
assertTrue(returnedId.isNotBlank())
|
||||
assertEquals(returnedId, onSentId)
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("session.interrupt", (frame["method"] as? JsonPrimitive)?.content)
|
||||
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(setOf("session_id"), params!!.keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedSendSessionSteerPutsExpectedFrameAndInvokesOnSent() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.steer") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
var onSentId: String? = null
|
||||
val returnedId =
|
||||
HermesWsClient.send(
|
||||
method = RpcMethods.SESSION_STEER,
|
||||
params = SessionCorrectionParams(sessionId = "s1", text = "steer towards plan B"),
|
||||
onSent = { id -> onSentId = id },
|
||||
)
|
||||
|
||||
assertTrue(returnedId.isNotBlank())
|
||||
assertEquals(returnedId, onSentId)
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("session.steer", (frame["method"] as? JsonPrimitive)?.content)
|
||||
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("text", "steer towards plan B")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals(setOf("session_id", "text"), params!!.keys)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,584 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptBtwParams
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptBtwResult
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionBranchParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionBranchWholeParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionCompressParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionListParams
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class HermesWsClientSessionMiscTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
AuthManager.resetAuthStateForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedSendSessionListPutsEmptyParamsOnWireWithoutActiveProfile() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.list") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
var onSentId: String? = null
|
||||
val returnedId =
|
||||
HermesWsClient.send(
|
||||
method = RpcMethods.SESSION_LIST,
|
||||
params = SessionListParams,
|
||||
onSent = { id -> onSentId = id },
|
||||
)
|
||||
|
||||
assertTrue(returnedId.isNotBlank())
|
||||
assertEquals(returnedId, onSentId)
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("session.list", (frame["method"] as? JsonPrimitive)?.content)
|
||||
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
|
||||
|
||||
// An empty params object is omitted on the wire (encodeDefaults=false), same as before the migration.
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertTrue("session.list carries no params", params == null || params.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedSendSessionListWithActiveProfileInjectsProfileOnWire() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.list") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
AuthManager.setActiveProfileId("active-profile-sessions")
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
HermesWsClient.send(
|
||||
method = RpcMethods.SESSION_LIST,
|
||||
params = SessionListParams,
|
||||
)
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("profile", "active-profile-sessions")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallPromptBtwRoundTripsTaskIdAndReturnsPromptBtwResult() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "prompt.btw") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":{"task_id":"task-async-123","status":"queued"}}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: PromptBtwResult =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.PROMPT_BTW,
|
||||
PromptBtwParams(sessionId = "sess-btw-call", text = "remember this detail"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
assertEquals("prompt.btw", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-btw-call")
|
||||
put("text", "remember this detail")
|
||||
}
|
||||
assertEquals(expectedParams, frame["params"])
|
||||
assertEquals("task-async-123", result.taskId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallSessionUsageReturnsRawResultJsonElementUntouched() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResultJson =
|
||||
buildJsonObject {
|
||||
put("prompt_tokens", 1024)
|
||||
put("completion_tokens", 256)
|
||||
put("total_tokens", 1280)
|
||||
put("cost_usd", 0.015)
|
||||
put(
|
||||
"nested_stats",
|
||||
buildJsonObject {
|
||||
put("cache_read_tokens", 512)
|
||||
put("reasoning_tokens", 64)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.usage") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResultJson}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_USAGE,
|
||||
SessionIdParams(sessionId = "s1"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
assertEquals("session.usage", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
}
|
||||
assertEquals(expectedParams, frame["params"])
|
||||
assertEquals(serverResultJson, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallSessionCompressWithCustomTimeoutPutsExactWirePayload() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.compress") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":{"status":"compressed"}}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
method = RpcMethods.SESSION_COMPRESS,
|
||||
params = SessionCompressParams(sessionId = "sess-long-comp", focusTopic = "deep-dive"),
|
||||
timeoutMs = 300_000L,
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
assertEquals("session.compress", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-long-comp")
|
||||
put("focus_topic", "deep-dive")
|
||||
}
|
||||
assertEquals(expectedParams, frame["params"])
|
||||
|
||||
val expectedResult =
|
||||
buildJsonObject {
|
||||
put("status", "compressed")
|
||||
}
|
||||
assertEquals(expectedResult, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallSessionBranchPutsExactWirePayloadAndDecodesPassthroughResult() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResult =
|
||||
buildJsonObject {
|
||||
put("new_session_id", "sess-branched-99")
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.branch") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResult}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_BRANCH,
|
||||
SessionBranchParams(sessionId = "sess-orig-1", name = "branch-name"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
assertEquals("session.branch", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-orig-1")
|
||||
put("name", "branch-name")
|
||||
}
|
||||
assertEquals(expectedParams, frame["params"])
|
||||
assertEquals(serverResult, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallSessionBranchWholePutsExactWirePayloadAndDecodesPassthroughResult() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResult =
|
||||
buildJsonObject {
|
||||
put("whole_branch_session_id", "sess-whole-branched-101")
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.branch_whole") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResult}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_BRANCH_WHOLE,
|
||||
SessionBranchWholeParams(sessionId = "sess-whole-orig-1", name = null),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
assertEquals("session.branch_whole", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-whole-orig-1")
|
||||
}
|
||||
assertEquals(expectedParams, frame["params"])
|
||||
assertEquals(serverResult, result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallSessionContextBreakdownPutsExactWirePayloadAndDecodesPassthroughResult() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
val serverResult =
|
||||
buildJsonObject {
|
||||
put("system_prompt", 450)
|
||||
put("messages", 2300)
|
||||
put("tools", 800)
|
||||
}
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.context_breakdown") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":$serverResult}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result: JsonElement =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_CONTEXT_BREAKDOWN,
|
||||
SessionIdParams(sessionId = "sess-bd-1"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
assertEquals("session.context_breakdown", (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-bd-1")
|
||||
}
|
||||
assertEquals(expectedParams, frame["params"])
|
||||
assertEquals(serverResult, result)
|
||||
}
|
||||
}
|
||||
@@ -91,27 +91,15 @@ class HermesWsClientTest {
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
// Reset state
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
||||
statusField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.DISCONNECTED
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>).clear()
|
||||
HermesWsClient.connectedForTest.set(false)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.DISCONNECTED
|
||||
HermesWsClient.messageQueueForTest.clear()
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true) // Ensure it starts clean
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
val acceptQueuedMessagesField = HermesWsClient::class.java.getDeclaredField("acceptQueuedMessages")
|
||||
acceptQueuedMessagesField.isAccessible = true
|
||||
(acceptQueuedMessagesField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.acceptQueuedMessagesForTest.set(true)
|
||||
}
|
||||
|
||||
@After
|
||||
@@ -350,10 +338,8 @@ class HermesWsClientTest {
|
||||
val socket = mockk<WebSocket>()
|
||||
every { socket.send(any<String>()) } returns true
|
||||
every { socket.close(any(), any()) } returns true
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
|
||||
socketField.set(HermesWsClient, socket)
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
|
||||
val sent =
|
||||
HermesWsClient.respondToServerRequest(
|
||||
@@ -384,10 +370,8 @@ class HermesWsClientTest {
|
||||
val socket = mockk<WebSocket>()
|
||||
every { socket.send(any<String>()) } returns true
|
||||
every { socket.close(any(), any()) } returns true
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket").apply { isAccessible = true }
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected").apply { isAccessible = true }
|
||||
socketField.set(HermesWsClient, socket)
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
|
||||
assertTrue(HermesWsClient.respondToServerRequestError("srq-abc123", -32601, "unsupported"))
|
||||
verify {
|
||||
@@ -416,29 +400,14 @@ class HermesWsClientTest {
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { staleSocket.send(any<String>()) } returns false
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, staleSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
||||
statusField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
(statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>).value = ConnectionStatus.CONNECTED
|
||||
HermesWsClient.webSocketForTest = staleSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.CONNECTED
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
assertEquals(1, queue.size)
|
||||
@@ -446,8 +415,7 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testSendRegistersRequestIdWhileOutboundLockHeld() {
|
||||
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock").apply { isAccessible = true }
|
||||
val outboundLock = lockField.get(HermesWsClient)
|
||||
val outboundLock = HermesWsClient.outboundLockForTest
|
||||
var callbackHeldLock = false
|
||||
|
||||
HermesWsClient.send("test.method", onSent = { callbackHeldLock = Thread.holdsLock(outboundLock) })
|
||||
@@ -458,25 +426,13 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testNetworkChangeInvalidatesOldSocketAndOpensReplacementImmediately() {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns true
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
|
||||
val oldGeneration = generation.get()
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val staleListener = constructor.newInstance(oldGeneration) as WebSocketListener
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
val oldGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||
val staleListener = HermesWsClient.createListenerForTest(oldGeneration)
|
||||
var replacementOpens = 0
|
||||
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
|
||||
|
||||
@@ -486,10 +442,10 @@ class HermesWsClientTest {
|
||||
|
||||
verify(exactly = 1) { socket.cancel() }
|
||||
assertEquals(1, replacementOpens)
|
||||
assertEquals(oldGeneration + 1, generation.get())
|
||||
assertEquals(oldGeneration + 1, HermesWsClient.connectionGenerationForTest.get())
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(ConnectionStatus.RECONNECTING, HermesWsClient.connectionStatus.value)
|
||||
assertEquals(null, socketField.get(HermesWsClient))
|
||||
assertEquals(null, HermesWsClient.webSocketForTest)
|
||||
assertTrue(deferred.isCompleted)
|
||||
}
|
||||
|
||||
@@ -497,18 +453,10 @@ class HermesWsClientTest {
|
||||
fun testNetworkLossPreservesAuthExpiredStatus() =
|
||||
runBlocking {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
||||
statusField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
|
||||
HermesWsClient.reconnectForNetworkChange(false)
|
||||
HermesWsClient.reconnectForNetworkChange(true)
|
||||
@@ -520,22 +468,13 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testBreakBeforeMakeDoesNotReplayAcceptedRequest() {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
val oldSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { oldSocket.send(any<String>()) } returns true
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, oldSocket)
|
||||
HermesWsClient.webSocketForTest = oldSocket
|
||||
HermesWsClient.send(WsMethods.SUBSCRIPTION_CHANGE, mapOf("cancel" to true))
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertTrue(queue.isEmpty())
|
||||
|
||||
HermesWsClient.reconnectForNetworkChange(false)
|
||||
@@ -545,13 +484,7 @@ class HermesWsClientTest {
|
||||
HermesWsClient.reconnectForNetworkChange(true) { replacementOpens++ }
|
||||
val replacementSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { replacementSocket.send(any<String>()) } returns true
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val replacementListener = constructor.newInstance(generation.get()) as WebSocketListener
|
||||
val replacementListener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
|
||||
replacementListener.onOpen(replacementSocket, mockk(relaxed = true))
|
||||
|
||||
assertEquals(1, replacementOpens)
|
||||
@@ -563,18 +496,10 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testNetworkChangePreservesAuthExpiredSocket() {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
||||
statusField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
|
||||
HermesWsClient.reconnectForNetworkChange()
|
||||
|
||||
@@ -588,20 +513,13 @@ class HermesWsClientTest {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.currentBackoffForTest = 1_000L
|
||||
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
backoffField.setLong(HermesWsClient, 1_000L)
|
||||
HermesWsClient.scheduleReconnectForTest()
|
||||
HermesWsClient.scheduleReconnectForTest()
|
||||
|
||||
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
||||
reconnectMethod.isAccessible = true
|
||||
reconnectMethod.invoke(HermesWsClient)
|
||||
reconnectMethod.invoke(HermesWsClient)
|
||||
|
||||
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
||||
assertEquals(2_000L, HermesWsClient.currentBackoffForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -610,35 +528,21 @@ class HermesWsClientTest {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectedForTest.set(false)
|
||||
HermesWsClient.currentBackoffForTest = 0L
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
backoffField.setLong(HermesWsClient, 0L)
|
||||
|
||||
val reconnectJobField = HermesWsClient::class.java.getDeclaredField("reconnectJob")
|
||||
reconnectJobField.isAccessible = true
|
||||
val replacementJob = mockk<Job>(relaxed = true)
|
||||
|
||||
val lockField = HermesWsClient::class.java.getDeclaredField("outboundLock")
|
||||
lockField.isAccessible = true
|
||||
val lock = lockField.get(HermesWsClient)
|
||||
val reconnectMethod = HermesWsClient::class.java.getDeclaredMethod("scheduleReconnect")
|
||||
reconnectMethod.isAccessible = true
|
||||
val lock = HermesWsClient.outboundLockForTest
|
||||
|
||||
synchronized(lock) {
|
||||
reconnectMethod.invoke(HermesWsClient)
|
||||
reconnectJobField.set(HermesWsClient, replacementJob)
|
||||
HermesWsClient.scheduleReconnectForTest()
|
||||
HermesWsClient.reconnectJobForTest = replacementJob
|
||||
}
|
||||
|
||||
Thread.sleep(100)
|
||||
assertSame(replacementJob, reconnectJobField.get(HermesWsClient))
|
||||
assertSame(replacementJob, HermesWsClient.reconnectJobForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -646,23 +550,15 @@ class HermesWsClientTest {
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { staleSocket.send(any<String>()) } returns false
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, staleSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.webSocketForTest = staleSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
|
||||
HermesWsClient.send(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
||||
)
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertTrue(queue.isEmpty())
|
||||
io.mockk.verify(exactly = 0) { staleSocket.cancel() }
|
||||
}
|
||||
@@ -674,10 +570,7 @@ class HermesWsClientTest {
|
||||
mapOf("session_id" to "s1", "text" to "x".repeat(16 * 1024 * 1024 + 1)),
|
||||
)
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertTrue(queue.isEmpty())
|
||||
}
|
||||
|
||||
@@ -687,24 +580,14 @@ class HermesWsClientTest {
|
||||
every { pressuredSocket.send(any<String>()) } returns false
|
||||
every { pressuredSocket.queueSize() } returns 1024L
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, pressuredSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.webSocketForTest = pressuredSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
||||
drainJobField.isAccessible = true
|
||||
assertNotNull(drainJobField.get(HermesWsClient))
|
||||
assertNotNull(HermesWsClient.outboundDrainJobForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -717,35 +600,18 @@ class HermesWsClientTest {
|
||||
every { pressuredSocket.send(any<String>()) } returns false
|
||||
every { pressuredSocket.queueSize() } returns 1024L
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, pressuredSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
backoffField.setLong(HermesWsClient, 1_000L)
|
||||
HermesWsClient.webSocketForTest = pressuredSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
HermesWsClient.currentBackoffForTest = 1_000L
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
listener.onFailure(pressuredSocket, IOException("connection lost"), null)
|
||||
|
||||
assertEquals(2_000L, backoffField.getLong(HermesWsClient))
|
||||
assertEquals(2_000L, HermesWsClient.currentBackoffForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -754,38 +620,22 @@ class HermesWsClientTest {
|
||||
every { pressuredSocket.send(any<String>()) } returnsMany listOf(true, false)
|
||||
every { pressuredSocket.queueSize() } returns 1024L
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
|
||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"2\"}")
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
HermesWsClient.webSocketForTest = pressuredSocket
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, pressuredSocket)
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
listener.onOpen(pressuredSocket, mockk(relaxed = true))
|
||||
|
||||
assertFalse(HermesWsClient.isConnected)
|
||||
assertEquals(1, queue.size)
|
||||
assertTrue(queue.peek()?.contains("\"2\"") == true)
|
||||
io.mockk.verify(exactly = 0) { pressuredSocket.cancel() }
|
||||
val drainJobField = HermesWsClient::class.java.getDeclaredField("outboundDrainJob")
|
||||
drainJobField.isAccessible = true
|
||||
assertNotNull(drainJobField.get(HermesWsClient))
|
||||
assertNotNull(HermesWsClient.outboundDrainJobForTest)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -794,29 +644,15 @@ class HermesWsClientTest {
|
||||
val currentSocket = mockk<WebSocket>(relaxed = true)
|
||||
every { currentSocket.send(any<String>()) } returns true
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, currentSocket)
|
||||
HermesWsClient.webSocketForTest = currentSocket
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(2)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(2)
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
queue.add("{\"jsonrpc\":\"2.0\",\"id\":\"1\"}")
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val staleListener = constructor.newInstance(1) as WebSocketListener
|
||||
val currentListener = constructor.newInstance(2) as WebSocketListener
|
||||
val staleListener = HermesWsClient.createListenerForTest(1)
|
||||
val currentListener = HermesWsClient.createListenerForTest(2)
|
||||
val response = mockk<okhttp3.Response>(relaxed = true)
|
||||
|
||||
staleListener.onOpen(staleSocket, response)
|
||||
@@ -835,40 +671,24 @@ class HermesWsClientTest {
|
||||
false
|
||||
}
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, staleSocket)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.webSocketForTest = staleSocket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertTrue(queue.isEmpty())
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, HermesWsClient.connectionStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDisconnectPreservesQueuedMessagesUnlessExplicitlyCleared() {
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertEquals(1, queue.size)
|
||||
|
||||
HermesWsClient.disconnect()
|
||||
@@ -886,16 +706,11 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testRejectAllPendingRemovesQueuedAwaitedRpc() {
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
|
||||
val deferred = HermesWsClient.request(WsMethods.PROCESS_LIST, mapOf("session_id" to "s1"))
|
||||
|
||||
val queueField = HermesWsClient::class.java.getDeclaredField("messageQueue")
|
||||
queueField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val queue = queueField.get(HermesWsClient) as java.util.concurrent.ConcurrentLinkedQueue<String>
|
||||
val queue = HermesWsClient.messageQueueForTest
|
||||
assertEquals(1, queue.size)
|
||||
|
||||
HermesWsClient.rejectAllPending()
|
||||
@@ -910,26 +725,12 @@ class HermesWsClientTest {
|
||||
every { socket.send(any<String>()) } returns false
|
||||
every { socket.queueSize() } returns 0L
|
||||
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
|
||||
listener.onClosing(socket, 4401, "expired")
|
||||
HermesWsClient.send(WsMethods.PROMPT_SUBMIT, mapOf("session_id" to "s1", "text" to "hello"))
|
||||
@@ -941,21 +742,10 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testSocketFailureDoesNotOverwriteAuthExpired() {
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
||||
statusField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
|
||||
listener.onFailure(socket, IOException("network changed"), null)
|
||||
|
||||
@@ -965,18 +755,10 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testExplicitConnectRetriesAfterAuthExpired() =
|
||||
runBlocking {
|
||||
val statusField = HermesWsClient::class.java.getDeclaredField("_connectionStatus")
|
||||
statusField.isAccessible = true
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val status = statusField.get(HermesWsClient) as MutableStateFlow<ConnectionStatus>
|
||||
status.value = ConnectionStatus.AUTH_EXPIRED
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.connectionStatusForTest.value = ConnectionStatus.AUTH_EXPIRED
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
val oldSocket = mockk<WebSocket>(relaxed = true)
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, oldSocket)
|
||||
HermesWsClient.webSocketForTest = oldSocket
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
|
||||
HermesWsClient.connect()
|
||||
@@ -1008,17 +790,16 @@ class HermesWsClientTest {
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
mockWebServer.enqueue(MockResponse().withWebSocketUpgrade(object : WebSocketListener() {}))
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
val generation = generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger
|
||||
val disconnectedGeneration = generation.get()
|
||||
val disconnectedGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||
val replacementConnect = thread { HermesWsClient.connect() }
|
||||
val generationDeadline = System.currentTimeMillis() + 5_000
|
||||
while (generation.get() == disconnectedGeneration && System.currentTimeMillis() < generationDeadline) {
|
||||
while (HermesWsClient.connectionGenerationForTest.get() == disconnectedGeneration &&
|
||||
System.currentTimeMillis() < generationDeadline
|
||||
) {
|
||||
Thread.sleep(10)
|
||||
}
|
||||
assertTrue(generation.get() > disconnectedGeneration)
|
||||
val currentGeneration = generation.get()
|
||||
assertTrue(HermesWsClient.connectionGenerationForTest.get() > disconnectedGeneration)
|
||||
val currentGeneration = HermesWsClient.connectionGenerationForTest.get()
|
||||
|
||||
releaseRefresh.countDown()
|
||||
staleConnect.join(5_000)
|
||||
@@ -1029,7 +810,7 @@ class HermesWsClientTest {
|
||||
|
||||
assertFalse(staleConnect.isAlive)
|
||||
assertFalse(replacementConnect.isAlive)
|
||||
assertEquals(currentGeneration, generation.get())
|
||||
assertEquals(currentGeneration, HermesWsClient.connectionGenerationForTest.get())
|
||||
assertEquals(ConnectionStatus.CONNECTED, HermesWsClient.connectionStatus.value)
|
||||
verify(exactly = 0) { AuthManager.setToken("stale-token") }
|
||||
}
|
||||
@@ -1441,12 +1222,10 @@ class HermesWsClientTest {
|
||||
runBlocking { withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } } }
|
||||
|
||||
// After connect, backoff should be back to initial
|
||||
val backoffField = HermesWsClient::class.java.getDeclaredField("currentBackoff")
|
||||
backoffField.isAccessible = true
|
||||
assertEquals(
|
||||
"Backoff should reset to initial after successful connect",
|
||||
1000L,
|
||||
backoffField.getLong(HermesWsClient),
|
||||
HermesWsClient.currentBackoffForTest,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1999,26 +1778,11 @@ class HermesWsClientTest {
|
||||
fun testStreamingDedupPreservesSessionFallbackAndNumericSequenceSemantics() {
|
||||
// Issue #1163: exercise the real listener, including the early duplicate return.
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
val intentionalClose = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalClose.isAccessible = true
|
||||
(intentionalClose.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
val generation = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generation.isAccessible = true
|
||||
val constructor =
|
||||
Class
|
||||
.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
.declaredConstructors
|
||||
.single()
|
||||
constructor.isAccessible = true
|
||||
val listener =
|
||||
constructor.newInstance(
|
||||
(generation.get(HermesWsClient) as AtomicInteger).get(),
|
||||
) as WebSocketListener
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
val listener = HermesWsClient.createListenerForTest(HermesWsClient.connectionGenerationForTest.get())
|
||||
// Directly install the reflection-built listener so onMessage runs even
|
||||
// though this test never performed a real OkHttp connect.
|
||||
val socketField = HermesWsClient::class.java.getDeclaredField("webSocket")
|
||||
socketField.isAccessible = true
|
||||
socketField.set(HermesWsClient, socket)
|
||||
HermesWsClient.webSocketForTest = socket
|
||||
mockkObject(EventParser)
|
||||
|
||||
val seqValues = listOf("6", "6.9", "4294967302", "\"6\"", "null", "true", "{}", "[]")
|
||||
@@ -2039,18 +1803,10 @@ class HermesWsClientTest {
|
||||
@Test
|
||||
fun testTerminalCloseCode4403SetsAuthExpired() {
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
val intentionalCloseField = HermesWsClient::class.java.getDeclaredField("intentionalClose")
|
||||
intentionalCloseField.isAccessible = true
|
||||
(intentionalCloseField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.intentionalCloseForTest.set(false)
|
||||
HermesWsClient.connectionGenerationForTest.set(1)
|
||||
|
||||
val generationField = HermesWsClient::class.java.getDeclaredField("connectionGeneration")
|
||||
generationField.isAccessible = true
|
||||
(generationField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicInteger).set(1)
|
||||
|
||||
val listenerClass = Class.forName("com.m57.hermescontrol.data.ws.HermesWsClient\$WsListenerImpl")
|
||||
val constructor = listenerClass.declaredConstructors.single()
|
||||
constructor.isAccessible = true
|
||||
val listener = constructor.newInstance(1) as WebSocketListener
|
||||
val listener = HermesWsClient.createListenerForTest(1)
|
||||
|
||||
listener.onClosing(socket, 4403, "forbidden origin")
|
||||
assertEquals(ConnectionStatus.AUTH_EXPIRED, HermesWsClient.connectionStatus.value)
|
||||
@@ -2182,25 +1938,22 @@ class HermesWsClientTest {
|
||||
|
||||
@Test
|
||||
fun testProbeLivenessOnTransportChangeCancelsSocketWhenPingFails() {
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(true)
|
||||
HermesWsClient.connectedForTest.set(true)
|
||||
|
||||
val failureLatch = CountDownLatch(1)
|
||||
HermesWsClient.probeLivenessOnTransportChange(
|
||||
timeoutMs = 50L,
|
||||
onFailureAction = {
|
||||
failureLatch.countDown()
|
||||
},
|
||||
)
|
||||
|
||||
assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(6, TimeUnit.SECONDS))
|
||||
assertTrue("Expected failure action to be invoked on failed ping", failureLatch.await(3, TimeUnit.SECONDS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testProbeLivenessOnTransportChangeSkipsWhenDisconnected() {
|
||||
val connectedField = HermesWsClient::class.java.getDeclaredField("connected")
|
||||
connectedField.isAccessible = true
|
||||
(connectedField.get(HermesWsClient) as java.util.concurrent.atomic.AtomicBoolean).set(false)
|
||||
HermesWsClient.connectedForTest.set(false)
|
||||
|
||||
var failureInvoked = false
|
||||
HermesWsClient.probeLivenessOnTransportChange(
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class HermesWsClientTypedCallTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Serializable
|
||||
private data class ScopedDummyParams(
|
||||
val query: String,
|
||||
val profile: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ScopedDummyResult(
|
||||
val status: String? = null,
|
||||
)
|
||||
|
||||
private val sessionListMethod =
|
||||
RpcMethod(
|
||||
name = WsMethods.SESSION_LIST,
|
||||
params = ScopedDummyParams.serializer(),
|
||||
result = ScopedDummyResult.serializer(),
|
||||
)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
AuthManager.resetAuthStateForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callPutsExactFrameOnTheWire() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == WsMethods.SESSION_EVENTS_SINCE) {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":{"latest_seq":7,"truncated":false}}""",
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 7),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertEquals(WsMethods.SESSION_EVENTS_SINCE, (frame["method"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("last_seen", 7)
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
|
||||
val lastSeenElement = params!!["last_seen"] as? JsonPrimitive
|
||||
assertNotNull(lastSeenElement)
|
||||
assertTrue(lastSeenElement!!.isString.not())
|
||||
assertEquals(7, lastSeenElement.content.toInt())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callDecodesTypedResultOnSuccess() {
|
||||
val opened = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","result":{"epoch":"ep-42","latest_seq":100,"truncated":true,"events":[{"type":"test"}]}}""",
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val result =
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 5),
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals("ep-42", result.epoch)
|
||||
assertEquals(100, result.latestSeq)
|
||||
assertEquals(true, result.truncated)
|
||||
assertEquals(1, result.events?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callThrowsHermesRpcExceptionOnErrorResponse() {
|
||||
val opened = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send(
|
||||
"""{"jsonrpc":"2.0","id":"$id","error":{"code":4001,"message":"Access denied: session ownership required","data":{"reason":"NOT_OWNER"}}}""",
|
||||
)
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
try {
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 1),
|
||||
)
|
||||
}
|
||||
fail("Expected HermesRpcException to be thrown")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals(4001, e.code)
|
||||
assertEquals("Access denied: session ownership required", e.message)
|
||||
val dataObj = e.data as? JsonObject
|
||||
assertNotNull(dataObj)
|
||||
assertEquals("NOT_OWNER", (dataObj!!["reason"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileScopingInjectsActiveProfileWhenSet() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == WsMethods.SESSION_LIST) {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""")
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
AuthManager.setActiveProfileId("work-profile")
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
sessionListMethod,
|
||||
ScopedDummyParams(query = "active"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
assertEquals("work-profile", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||
assertEquals("active", (params["query"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileScopingPreservesExplicitProfile() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == WsMethods.SESSION_LIST) {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""")
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
AuthManager.setActiveProfileId("work-profile")
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
sessionListMethod,
|
||||
ScopedDummyParams(query = "active", profile = "explicit-override"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
assertEquals("explicit-override", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun profileScopingDoesNotInjectWhenNoActiveProfile() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == WsMethods.SESSION_LIST) {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
val id = (json["id"] as? JsonPrimitive)?.content ?: "1"
|
||||
webSocket.send("""{"jsonrpc":"2.0","id":"$id","result":{"status":"ok"}}""")
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
sessionListMethod,
|
||||
ScopedDummyParams(query = "active"),
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
assertFalse("Profile key must not be present when no active profile", params!!.containsKey("profile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun callFailsOnTimeoutWhenServerDoesNotReply() {
|
||||
val opened = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
// Do not reply
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
val thrown =
|
||||
runCatching {
|
||||
runBlocking {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 1),
|
||||
timeoutMs = 50L,
|
||||
)
|
||||
}
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertTrue(
|
||||
"Timeout must surface as HermesRpcException, got: $thrown",
|
||||
thrown is HermesWsClient.HermesRpcException,
|
||||
)
|
||||
val rpc = thrown as HermesWsClient.HermesRpcException
|
||||
assertEquals(-1, rpc.code)
|
||||
assertTrue(rpc.message.orEmpty().contains("timed out"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cancellingCallingCoroutineCleansPendingCall() {
|
||||
val opened = CountDownLatch(1)
|
||||
val requestReceived = CountDownLatch(1)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
requestReceived.countDown()
|
||||
// Do not reply, keeping the call pending
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
runBlocking {
|
||||
val callJob =
|
||||
async(Dispatchers.IO) {
|
||||
HermesWsClient.call(
|
||||
RpcMethods.SESSION_EVENTS_SINCE,
|
||||
SessionEventsSinceParams(sessionId = "s1", lastSeen = 1),
|
||||
timeoutMs = 10_000L,
|
||||
)
|
||||
}
|
||||
|
||||
assertTrue("Server should receive request", requestReceived.await(5, TimeUnit.SECONDS))
|
||||
|
||||
assertEquals(
|
||||
"Should have exactly 1 pending call while awaiting",
|
||||
1,
|
||||
HermesWsClient.pendingCallIdsForTest().size,
|
||||
)
|
||||
|
||||
callJob.cancel()
|
||||
|
||||
// Wait briefly for cancellation cleanup to execute
|
||||
withTimeout(2000) {
|
||||
while (HermesWsClient.pendingCallIdsForTest().isNotEmpty()) {
|
||||
kotlinx.coroutines.delay(20)
|
||||
}
|
||||
}
|
||||
|
||||
assertTrue(
|
||||
"Pending calls should be empty after caller coroutine is cancelled",
|
||||
HermesWsClient.pendingCallIdsForTest().isEmpty(),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
import com.m57.hermescontrol.data.remote.DashboardSessionTokenRefresher
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.buildFakePersistentCookieJar
|
||||
import com.m57.hermescontrol.data.ws.contract.PromptSubmitParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionCreateParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionResumeParams
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
import okhttp3.WebSocketListener
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
class HermesWsClientTypedSendTest {
|
||||
private lateinit var mockWebServer: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
mockWebServer = MockWebServer()
|
||||
mockWebServer.start()
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.wsUrl() } returns mockWebServer.url("/").toString().replace("http://", "ws://")
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { AuthManager.getSessionCookie() } returns null
|
||||
every { AuthManager.serverStore } returns
|
||||
mockk<com.m57.hermescontrol.data.config.ServerStore>().also {
|
||||
every { it.getLatestState() } returns
|
||||
com.m57.hermescontrol.data.config
|
||||
.ServerStoreState()
|
||||
}
|
||||
CookieManager.setJarForTest(buildFakePersistentCookieJar())
|
||||
mockkObject(DashboardSessionTokenRefresher)
|
||||
coEvery { DashboardSessionTokenRefresher.refreshAsync() } returns null
|
||||
every { DashboardSessionTokenRefresher.refresh() } returns null
|
||||
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.setAppForeground(true)
|
||||
AuthManager.resetAuthStateForTest()
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
AuthManager.resetAuthStateForTest()
|
||||
HermesWsClient.releaseExternalActivityConnectionLease()
|
||||
HermesWsClient.releaseBackgroundConnectionLease()
|
||||
HermesWsClient.disconnect(clearPendingMessages = true)
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.DISCONNECTED } }
|
||||
}
|
||||
runCatching { mockWebServer.shutdown() }
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
private fun connectClient() {
|
||||
HermesWsClient.connect()
|
||||
runBlocking {
|
||||
withTimeout(5000) { HermesWsClient.connectionStatus.first { it == ConnectionStatus.CONNECTED } }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedSendSessionResumePutsExpectedFrameAndInvokesOnSent() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.resume") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
var onSentId: String? = null
|
||||
val returnedId =
|
||||
HermesWsClient.send(
|
||||
method = RpcMethods.SESSION_RESUME,
|
||||
params = SessionResumeParams(sessionId = "sess-1", omitMessages = true),
|
||||
onSent = { id -> onSentId = id },
|
||||
)
|
||||
|
||||
assertTrue(returnedId.isNotBlank())
|
||||
assertEquals(returnedId, onSentId)
|
||||
|
||||
assertTrue("Frame must reach the server", captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
|
||||
assertFalse("Outgoing frames have NO jsonrpc field", frame.containsKey("jsonrpc"))
|
||||
assertEquals("session.resume", (frame["method"] as? JsonPrimitive)?.content)
|
||||
assertEquals(returnedId, (frame["id"] as? JsonPrimitive)?.content)
|
||||
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-1")
|
||||
put("omit_messages", true)
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedSendWithActiveProfileInjectsProfileIntoSessionResume() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.resume") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
AuthManager.setActiveProfileId("active-profile-1")
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
HermesWsClient.send(
|
||||
method = RpcMethods.SESSION_RESUME,
|
||||
params = SessionResumeParams(sessionId = "sess-active", omitMessages = true),
|
||||
)
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-active")
|
||||
put("omit_messages", true)
|
||||
put("profile", "active-profile-1")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals("active-profile-1", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedSendExplicitProfileWinsOverActiveProfileForSessionResume() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.resume") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
AuthManager.setActiveProfileId("active-profile-1")
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
HermesWsClient.send(
|
||||
method = RpcMethods.SESSION_RESUME,
|
||||
params =
|
||||
SessionResumeParams(
|
||||
sessionId = "sess-active",
|
||||
omitMessages = true,
|
||||
profile = "explicit-resume-profile",
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-active")
|
||||
put("omit_messages", true)
|
||||
put("profile", "explicit-resume-profile")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals("explicit-resume-profile", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCreateWithExplicitProfileKeepsExplicitWhenDifferentProfileActive() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "session.create") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
AuthManager.setActiveProfileId("active-profile-2")
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
HermesWsClient.send(
|
||||
method = RpcMethods.SESSION_CREATE,
|
||||
params = SessionCreateParams(profile = "bot-1"),
|
||||
)
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("profile", "bot-1")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertEquals("bot-1", (params!!["profile"] as? JsonPrimitive)?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessagePutsSessionIdAndTextOnTheWire() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "prompt.submit") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
var onSentId: String? = null
|
||||
val returnedId =
|
||||
HermesWsClient.sendMessage(
|
||||
sessionId = "sess-msg-1",
|
||||
text = "plain message",
|
||||
onSent = { id -> onSentId = id },
|
||||
)
|
||||
|
||||
assertTrue(returnedId.isNotBlank())
|
||||
assertEquals(returnedId, onSentId)
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-msg-1")
|
||||
put("text", "plain message")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertFalse(params!!.containsKey("queued"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessageWithQueuedTrueAddsQueuedTrue() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "prompt.submit") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
HermesWsClient.sendMessage(
|
||||
sessionId = "sess-msg-2",
|
||||
text = "queued message",
|
||||
queued = true,
|
||||
)
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-msg-2")
|
||||
put("text", "queued message")
|
||||
put("queued", true)
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
val queuedElement = params!!["queued"] as? JsonPrimitive
|
||||
assertTrue(queuedElement != null && queuedElement.isString.not())
|
||||
assertEquals("true", queuedElement!!.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendMessageWithQueuedFalseHasNoQueuedKey() {
|
||||
val opened = CountDownLatch(1)
|
||||
val captured = CountDownLatch(1)
|
||||
val frameRef = AtomicReference<JsonObject?>(null)
|
||||
|
||||
mockWebServer.enqueue(
|
||||
MockResponse().withWebSocketUpgrade(
|
||||
object : WebSocketListener() {
|
||||
override fun onOpen(
|
||||
webSocket: WebSocket,
|
||||
response: Response,
|
||||
) = opened.countDown()
|
||||
|
||||
override fun onMessage(
|
||||
webSocket: WebSocket,
|
||||
text: String,
|
||||
) {
|
||||
val json = OkHttpProvider.json.parseToJsonElement(text) as? JsonObject ?: return
|
||||
val name = (json["method"] as? JsonPrimitive)?.content
|
||||
if (name == "prompt.submit") {
|
||||
frameRef.set(json)
|
||||
captured.countDown()
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
connectClient()
|
||||
assertTrue(opened.await(5, TimeUnit.SECONDS))
|
||||
|
||||
HermesWsClient.sendMessage(
|
||||
sessionId = "sess-msg-3",
|
||||
text = "un为其queued message",
|
||||
queued = false,
|
||||
)
|
||||
|
||||
assertTrue(captured.await(5, TimeUnit.SECONDS))
|
||||
val frame = requireNotNull(frameRef.get())
|
||||
val params = frame["params"] as? JsonObject
|
||||
assertNotNull(params)
|
||||
|
||||
val expectedParams =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-msg-3")
|
||||
put("text", "un为其queued message")
|
||||
}
|
||||
assertEquals(expectedParams, params)
|
||||
assertFalse(params!!.containsKey("queued"))
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.ws.contract.ModelOptionsParams
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
@@ -21,7 +22,7 @@ class ModelOptionsRepositoryTest {
|
||||
ModelOptionsRepository(
|
||||
connected = { true },
|
||||
request = { params ->
|
||||
assertEquals(mapOf("refresh" to true, "include_unconfigured" to false), params)
|
||||
assertEquals(ModelOptionsParams(refresh = true, includeUnconfigured = false), params)
|
||||
Json.parseToJsonElement("""{"providers":[{"slug":"test","name":"Test","models":["a"]}]}""")
|
||||
},
|
||||
rest = { error("REST must not run") },
|
||||
|
||||
@@ -16,6 +16,9 @@ import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
class PluginManageRepositoryTest {
|
||||
private fun pluginRepo(handler: suspend (String, Map<String, Any>) -> Any?) =
|
||||
PluginManageRepository(fakeCaller(handler))
|
||||
|
||||
// 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`() =
|
||||
@@ -44,7 +47,7 @@ class PluginManageRepositoryTest {
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
pluginRepo { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockPayload
|
||||
@@ -80,7 +83,7 @@ class PluginManageRepositoryTest {
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
pluginRepo { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockPayload
|
||||
@@ -98,19 +101,19 @@ class PluginManageRepositoryTest {
|
||||
fun `listPlugins returns empty list when plugins key is missing or response is not a JsonObject`() =
|
||||
runTest {
|
||||
val emptyObjRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
pluginRepo { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("{}")
|
||||
}
|
||||
assertTrue(emptyObjRepo.listPlugins().isEmpty())
|
||||
|
||||
val nonObjRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
pluginRepo { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("[]")
|
||||
}
|
||||
assertTrue(nonObjRepo.listPlugins().isEmpty())
|
||||
|
||||
val nullRepo =
|
||||
PluginManageRepository { _, _ ->
|
||||
pluginRepo { _, _ ->
|
||||
null
|
||||
}
|
||||
assertTrue(nullRepo.listPlugins().isEmpty())
|
||||
@@ -139,7 +142,7 @@ class PluginManageRepositoryTest {
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
pluginRepo { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockResponse
|
||||
@@ -156,7 +159,7 @@ class PluginManageRepositoryTest {
|
||||
assertEquals(WsMethods.PLUGINS_MANAGE, capturedMethod)
|
||||
assertEquals("settings", capturedParams["action"])
|
||||
assertEquals("camofox", capturedParams["key"])
|
||||
assertEquals(JsonObject(valuesToSave), capturedParams["values"])
|
||||
assertEquals(mapOf("port" to 9222, "headless" to true), capturedParams["values"])
|
||||
assertFalse(capturedParams.containsKey("profile"))
|
||||
|
||||
assertTrue(result.ok)
|
||||
@@ -186,7 +189,7 @@ class PluginManageRepositoryTest {
|
||||
)
|
||||
|
||||
val repository =
|
||||
PluginManageRepository { method, params ->
|
||||
pluginRepo { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
mockResponse
|
||||
@@ -204,7 +207,7 @@ class PluginManageRepositoryTest {
|
||||
mapOf(
|
||||
"action" to "settings",
|
||||
"key" to "camofox",
|
||||
"values" to JsonObject(valuesToSave),
|
||||
"values" to mapOf("port" to 8080),
|
||||
"profile" to "staging",
|
||||
),
|
||||
capturedParams,
|
||||
@@ -222,7 +225,7 @@ class PluginManageRepositoryTest {
|
||||
fun `saveSettings throws SerializationException when response is malformed`() =
|
||||
runTest {
|
||||
val repository =
|
||||
PluginManageRepository { _, _ ->
|
||||
pluginRepo { _, _ ->
|
||||
OkHttpProvider.json.parseToJsonElement("{\"ok\": \"not-a-boolean\"}")
|
||||
}
|
||||
|
||||
|
||||
@@ -9,13 +9,16 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PluginRemovalRepositoryTest {
|
||||
private fun removalRepo(handler: suspend (String, Map<String, Any>) -> Any?) =
|
||||
PluginRemovalRepository(fakeCaller(handler))
|
||||
|
||||
@Test
|
||||
fun `remove sends scoped gateway action and decodes success`() =
|
||||
runTest {
|
||||
var method = ""
|
||||
var params: Map<String, Any> = emptyMap()
|
||||
val repository =
|
||||
PluginRemovalRepository { requestedMethod, requestedParams ->
|
||||
removalRepo { requestedMethod, requestedParams ->
|
||||
method = requestedMethod
|
||||
params = requestedParams
|
||||
Json.parseToJsonElement("""{"ok":true,"name":"demo"}""")
|
||||
@@ -33,11 +36,11 @@ class PluginRemovalRepositoryTest {
|
||||
@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") }
|
||||
val refused = removalRepo { _, _ -> 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") }
|
||||
val malformed = removalRepo { _, _ -> mapOf("name" to "demo") }
|
||||
try {
|
||||
malformed.remove("demo")
|
||||
throw AssertionError("Missing ok must fail decoding")
|
||||
|
||||
@@ -8,12 +8,17 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProjectsSourceTest {
|
||||
private fun projectsSource(
|
||||
connected: Boolean = true,
|
||||
handler: suspend (String, Map<String, Any>) -> Any?,
|
||||
) = HermesProjectsSource(isConnected = { connected }, caller = fakeCaller(handler))
|
||||
|
||||
@Test
|
||||
fun `decodes the untyped rpc map`() =
|
||||
runTest {
|
||||
var calledMethod: String? = null
|
||||
val source =
|
||||
HermesProjectsSource(isConnected = { true }) { method, _ ->
|
||||
projectsSource { method, _ ->
|
||||
calledMethod = method
|
||||
mapOf(
|
||||
"projects" to
|
||||
@@ -49,13 +54,10 @@ class ProjectsSourceTest {
|
||||
runTest {
|
||||
var calls = 0
|
||||
val source =
|
||||
HermesProjectsSource(
|
||||
rpcRequest = { _, _ ->
|
||||
calls++
|
||||
mapOf("projects" to emptyList<Any>())
|
||||
},
|
||||
isConnected = { false },
|
||||
)
|
||||
projectsSource(connected = false) { _, _ ->
|
||||
calls++
|
||||
mapOf("projects" to emptyList<Any>())
|
||||
}
|
||||
|
||||
assertNull(source.fetchProjects())
|
||||
assertEquals("a disconnected fetch must not queue an RPC", 0, calls)
|
||||
@@ -65,10 +67,10 @@ class ProjectsSourceTest {
|
||||
fun `rpc failure or unexpected payload yields null`() =
|
||||
runTest {
|
||||
assertNull(
|
||||
HermesProjectsSource(isConnected = { true }) { _, _ -> error("method not found") }.fetchProjects(),
|
||||
projectsSource { _, _ -> error("method not found") }.fetchProjects(),
|
||||
)
|
||||
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> "nope" }.fetchProjects())
|
||||
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> null }.fetchProjects())
|
||||
assertNull(projectsSource { _, _ -> "nope" }.fetchProjects())
|
||||
assertNull(projectsSource { _, _ -> null }.fetchProjects())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -76,9 +78,7 @@ class ProjectsSourceTest {
|
||||
runTest {
|
||||
val thrown =
|
||||
runCatching {
|
||||
HermesProjectsSource(
|
||||
isConnected = { true },
|
||||
) { _, _ -> throw CancellationException("gone") }.fetchProjects()
|
||||
projectsSource { _, _ -> throw CancellationException("gone") }.fetchProjects()
|
||||
}.exceptionOrNull()
|
||||
assertTrue(thrown is CancellationException)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.io.File
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
/**
|
||||
* Ratchet guard preventing new raw Map-param request/send call sites for contract RPC methods (#1375).
|
||||
*
|
||||
* Background:
|
||||
* Issue #1375 migrates raw `HermesWsClient.request(...)`/`.send(...)`/wrapper call sites that pass hand-built
|
||||
* `Map<String, Any>` params to typed `HermesWsClient.call(RpcMethods.X, params)`.
|
||||
*
|
||||
* This test asserts that the set/multiset of raw call sites in `app/src/main/java` matches [BASELINE].
|
||||
* - Adding a new raw call site fails CI: author must migrate to `HermesWsClient.call(RpcMethods.X, params)` or,
|
||||
* for a not-yet-migrated method, consciously add the entry to [BASELINE].
|
||||
* - Migrating a call site to typed `call(...)` makes the baseline entry stale, failing CI until removed.
|
||||
*
|
||||
* Scanner limitations / blind spots:
|
||||
* 1. Indirect wrappers: does not catch wrappers that alias method names through intermediate variables or functions
|
||||
* (e.g. `val m = WsMethods.FOO; send(m)` or helper functions accepting dynamic strings).
|
||||
* 2. Raw string literals: does not catch direct string literals bypassing `WsMethods` (e.g. `send("session.list")`).
|
||||
* 3. Dynamic dispatch: reflection or computed method identifiers are invisible to the static regex scanner.
|
||||
* 4. Custom parameter maps wrapped in separate helper classes or extensions not calling standard request/send.
|
||||
*/
|
||||
class RawRpcParamsRatchetTest {
|
||||
private val wsMethodConstants: Map<String, String> =
|
||||
WsMethods::class.java.declaredFields
|
||||
.filter { Modifier.isStatic(it.modifiers) && it.type == String::class.java }
|
||||
.associate { it.name to it.get(null) as String }
|
||||
|
||||
@Test
|
||||
fun rawRpcCallSitesMatchBaselineRatchet() {
|
||||
val rootDir = resolveMainJavaRoot()
|
||||
val contractMethodNames = GatewayContract.methods
|
||||
val contractConstantNames =
|
||||
wsMethodConstants
|
||||
.filter { (_, methodStr) -> methodStr in contractMethodNames }
|
||||
.keys
|
||||
|
||||
val scannedSites = mutableListOf<String>()
|
||||
rootDir
|
||||
.walkTopDown()
|
||||
.filter { it.isFile && it.extension == "kt" }
|
||||
.filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" }
|
||||
.forEach { file ->
|
||||
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
|
||||
val code = stripComments(file.readText())
|
||||
val sites = scanRawRpcCalls(code, contractConstantNames)
|
||||
for (site in sites) {
|
||||
scannedSites.add("$relativePath::$site")
|
||||
}
|
||||
}
|
||||
|
||||
scannedSites.sort()
|
||||
|
||||
val staleEntries = BASELINE.filter { it !in scannedSites }
|
||||
val newEntries = scannedSites.filter { it !in BASELINE }
|
||||
|
||||
val failureMessage =
|
||||
buildString {
|
||||
if (newEntries.isNotEmpty()) {
|
||||
appendLine(
|
||||
"raw call added: migrate to HermesWsClient.call(RpcMethods...) or, for a not-yet-migrated method, add to BASELINE:",
|
||||
)
|
||||
newEntries.forEach { appendLine(" + $it") }
|
||||
}
|
||||
if (staleEntries.isNotEmpty()) {
|
||||
appendLine("stale baseline entry: remove it (call site was migrated or removed):")
|
||||
staleEntries.forEach { appendLine(" - $it") }
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(
|
||||
failureMessage.trimEnd(),
|
||||
BASELINE,
|
||||
scannedSites,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun scannerSelfTestDetectsRawCallsAndIgnoresSafeUsages() {
|
||||
val contractConstants = setOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT")
|
||||
|
||||
val sampleCode =
|
||||
"""
|
||||
// Commented call should be ignored:
|
||||
// wsClient.request(WsMethods.SESSION_LIST, emptyMap())
|
||||
/* Multi-line comment:
|
||||
HermesWsClient.send(WsMethods.SESSION_LIST)
|
||||
*/
|
||||
|
||||
// When branch routing should be ignored:
|
||||
when (method) {
|
||||
WsMethods.SESSION_LIST -> handleList()
|
||||
WsMethods.SESSION_CREATE -> handleCreate()
|
||||
}
|
||||
|
||||
// Comparison checks should be ignored:
|
||||
if (method == WsMethods.SESSION_LIST) return
|
||||
if (method != WsMethods.PROMPT_SUBMIT) return
|
||||
|
||||
// Tracking helpers should be ignored:
|
||||
trackRequest(id, WsMethods.SESSION_LIST)
|
||||
trackSessionRequest(id, WsMethods.SESSION_CREATE, generation)
|
||||
|
||||
// Positional single-line call:
|
||||
HermesWsClient.request(WsMethods.SESSION_LIST, emptyMap())
|
||||
|
||||
// Named argument call across lines:
|
||||
request(
|
||||
method = WsMethods.SESSION_CREATE,
|
||||
params = mapOf("profile" to "test"),
|
||||
)
|
||||
|
||||
// Positional multi-line call:
|
||||
wsClient.send(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
mapOf("text" to "hello"),
|
||||
)
|
||||
""".trimIndent()
|
||||
|
||||
val cleaned = stripComments(sampleCode)
|
||||
val detected = scanRawRpcCalls(cleaned, contractConstants)
|
||||
|
||||
assertEquals(
|
||||
listOf("SESSION_LIST", "SESSION_CREATE", "PROMPT_SUBMIT"),
|
||||
detected,
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
internal fun resolveMainJavaRoot(): File {
|
||||
val userDir = File(System.getProperty("user.dir") ?: ".")
|
||||
val candidates =
|
||||
listOf(
|
||||
File(userDir, "app/src/main/java/com/m57/hermescontrol"),
|
||||
File(userDir, "src/main/java/com/m57/hermescontrol"),
|
||||
File(userDir, "../app/src/main/java/com/m57/hermescontrol"),
|
||||
)
|
||||
return candidates.firstOrNull { it.isDirectory }
|
||||
?: error("Could not resolve app/src/main/java/com/m57/hermescontrol from $userDir")
|
||||
}
|
||||
|
||||
internal fun stripComments(text: String): String {
|
||||
val result = StringBuilder(text.length)
|
||||
var i = 0
|
||||
val n = text.length
|
||||
var inString = false
|
||||
var inTripleString = false
|
||||
|
||||
while (i < n) {
|
||||
if (!inString && !inTripleString) {
|
||||
if (text.startsWith("\"\"\"", i)) {
|
||||
inTripleString = true
|
||||
result.append("\"\"\"")
|
||||
i += 3
|
||||
} else if (text[i] == '"') {
|
||||
inString = true
|
||||
result.append('"')
|
||||
i++
|
||||
} else if (text.startsWith("//", i)) {
|
||||
var j = text.indexOf('\n', i)
|
||||
if (j == -1) j = n
|
||||
while (i < j) {
|
||||
result.append(' ')
|
||||
i++
|
||||
}
|
||||
} else if (text.startsWith("/*", i)) {
|
||||
var depth = 1
|
||||
var j = i + 2
|
||||
while (j < n && depth > 0) {
|
||||
if (text.startsWith("/*", j)) {
|
||||
depth++
|
||||
j += 2
|
||||
} else if (text.startsWith("*/", j)) {
|
||||
depth--
|
||||
j += 2
|
||||
} else {
|
||||
j++
|
||||
}
|
||||
}
|
||||
while (i < j) {
|
||||
result.append(if (text[i] == '\n') '\n' else ' ')
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
result.append(text[i])
|
||||
i++
|
||||
}
|
||||
} else if (inTripleString) {
|
||||
if (text.startsWith("\"\"\"", i)) {
|
||||
inTripleString = false
|
||||
result.append("\"\"\"")
|
||||
i += 3
|
||||
} else {
|
||||
result.append(text[i])
|
||||
i++
|
||||
}
|
||||
} else {
|
||||
if (text[i] == '\\' && i + 1 < n) {
|
||||
result.append(text[i]).append(text[i + 1])
|
||||
i += 2
|
||||
} else if (text[i] == '"') {
|
||||
inString = false
|
||||
result.append('"')
|
||||
i++
|
||||
} else {
|
||||
result.append(text[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
}
|
||||
return result.toString()
|
||||
}
|
||||
|
||||
internal fun scanRawRpcCalls(
|
||||
cleanCode: String,
|
||||
contractConstants: Set<String>,
|
||||
): List<String> {
|
||||
val regex = Regex("""WsMethods\.([A-Za-z0-9_]+)""")
|
||||
val results = mutableListOf<String>()
|
||||
|
||||
for (match in regex.findAll(cleanCode)) {
|
||||
val constantName = match.groupValues[1]
|
||||
if (constantName !in contractConstants) continue
|
||||
|
||||
val startIndex = match.range.first
|
||||
if (isMethodArgumentOfRpcCall(cleanCode, startIndex)) {
|
||||
results.add(constantName)
|
||||
}
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
private fun isMethodArgumentOfRpcCall(
|
||||
cleanCode: String,
|
||||
matchStart: Int,
|
||||
): Boolean {
|
||||
var i = matchStart - 1
|
||||
while (i >= 0 && cleanCode[i].isWhitespace()) {
|
||||
i--
|
||||
}
|
||||
|
||||
var isNamedMethodParam = false
|
||||
if (i >= 0 && cleanCode[i] == '=') {
|
||||
var j = i - 1
|
||||
while (j >= 0 && cleanCode[j].isWhitespace()) {
|
||||
j--
|
||||
}
|
||||
val identEnd = j + 1
|
||||
while (j >= 0 && cleanCode[j].isLetterOrDigit()) {
|
||||
j--
|
||||
}
|
||||
val paramName = cleanCode.substring(j + 1, identEnd)
|
||||
if (paramName == "method") {
|
||||
isNamedMethodParam = true
|
||||
i = j
|
||||
while (i >= 0 && cleanCode[i].isWhitespace()) {
|
||||
i--
|
||||
}
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
if (i < 0) return false
|
||||
val delimiter = cleanCode[i]
|
||||
if (delimiter != '(' && delimiter != ',') return false
|
||||
if (delimiter == ',' && !isNamedMethodParam) {
|
||||
// Positional method argument in Kotlin RPC calls is the first argument directly following '('
|
||||
return false
|
||||
}
|
||||
|
||||
var parenDepth = 0
|
||||
var k = i
|
||||
while (k >= 0) {
|
||||
val c = cleanCode[k]
|
||||
if (c == ')') {
|
||||
parenDepth++
|
||||
} else if (c == '(') {
|
||||
if (parenDepth > 0) {
|
||||
parenDepth--
|
||||
} else {
|
||||
var m = k - 1
|
||||
while (m >= 0 && cleanCode[m].isWhitespace()) {
|
||||
m--
|
||||
}
|
||||
val endFn = m + 1
|
||||
while (m >= 0 &&
|
||||
(cleanCode[m].isLetterOrDigit() || cleanCode[m] == '.' || cleanCode[m] == '_')
|
||||
) {
|
||||
m--
|
||||
}
|
||||
val fnName = cleanCode.substring(m + 1, endFn)
|
||||
val lowerFn = fnName.lowercase()
|
||||
if (fnName in setOf("if", "while", "for", "when")) return false
|
||||
if (lowerFn.contains("track")) return false
|
||||
return true
|
||||
}
|
||||
} else if (c == '{' || c == '}' || c == ';') {
|
||||
return false
|
||||
}
|
||||
k--
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Multiset of remaining raw RPC call sites for contract methods in `app/src/main/java/com/m57/hermescontrol/`.
|
||||
* Empty: every contract method goes through `RpcMethods`. Keep it empty; new contract methods must be typed.
|
||||
* The untyped `request`/`send` stay module-internal for `gateway.ping` and the legacy `*.respond` fallbacks.
|
||||
*/
|
||||
val BASELINE: List<String> = emptyList()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ReconnectPolicyTest {
|
||||
@Test
|
||||
fun defaultInitialBackoffIs1000() {
|
||||
val policy = ReconnectPolicy()
|
||||
assertEquals(1000L, policy.currentBackoff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextDelayMsUsesInjectedRandomAndFloorsAt300() {
|
||||
// With random = 0.0, calculated delay is 0, floored to 300 (or ceiling if ceiling < 300)
|
||||
val minPolicy = ReconnectPolicy(random = { 0.0 })
|
||||
assertEquals(300L, minPolicy.nextDelayMs())
|
||||
|
||||
// With random = 0.5 and ceiling = 1000, 0.5 * 1000 = 500
|
||||
val midPolicy = ReconnectPolicy(random = { 0.5 })
|
||||
assertEquals(500L, midPolicy.nextDelayMs())
|
||||
|
||||
// With random nearly 1.0 (0.999), 0.999 * 1000 = 999
|
||||
val maxPolicy = ReconnectPolicy(random = { 0.999 })
|
||||
assertEquals(999L, maxPolicy.nextDelayMs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextDelayMsRespectsCeilingCapsBelowFloorAndZero() {
|
||||
// Ceiling <= 0 results in 0
|
||||
val zeroPolicy = ReconnectPolicy(random = { 0.5 })
|
||||
zeroPolicy.currentBackoff = 0L
|
||||
assertEquals(0L, zeroPolicy.nextDelayMs())
|
||||
|
||||
val negativePolicy = ReconnectPolicy(random = { 0.5 })
|
||||
negativePolicy.currentBackoff = -100L
|
||||
assertEquals(0L, negativePolicy.nextDelayMs())
|
||||
|
||||
// When ceiling is 100 (which is < 300 floor), delay should be capped to ceiling (100)
|
||||
val lowCeilingPolicy = ReconnectPolicy(random = { 0.0 })
|
||||
lowCeilingPolicy.currentBackoff = 100L
|
||||
assertEquals(100L, lowCeilingPolicy.nextDelayMs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun advanceDoublesBackoffUpToCapOf30000() {
|
||||
val policy = ReconnectPolicy()
|
||||
assertEquals(1000L, policy.currentBackoff)
|
||||
|
||||
policy.advance()
|
||||
assertEquals(2000L, policy.currentBackoff)
|
||||
|
||||
policy.advance()
|
||||
assertEquals(4000L, policy.currentBackoff)
|
||||
|
||||
policy.advance()
|
||||
assertEquals(8000L, policy.currentBackoff)
|
||||
|
||||
policy.advance()
|
||||
assertEquals(16000L, policy.currentBackoff)
|
||||
|
||||
// Doubles past 30000 should cap exactly at 30000 (not speculative 15000 or 32000)
|
||||
policy.advance()
|
||||
assertEquals(30000L, policy.currentBackoff)
|
||||
|
||||
policy.advance()
|
||||
assertEquals(30000L, policy.currentBackoff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resetRestoresInitialBackoffOf1000() {
|
||||
val policy = ReconnectPolicy()
|
||||
policy.advance()
|
||||
policy.advance()
|
||||
assertEquals(4000L, policy.currentBackoff)
|
||||
|
||||
policy.reset()
|
||||
assertEquals(1000L, policy.currentBackoff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun setBackoffForTestOverridesCurrentAndInitialBackoffDeterministically() {
|
||||
val policy = ReconnectPolicy(random = { 0.5 })
|
||||
policy.setBackoffForTest(1500L)
|
||||
|
||||
assertEquals(1500L, policy.currentBackoff)
|
||||
// With test override, delay should be deterministic override value
|
||||
assertEquals(1500L, policy.nextDelayMs())
|
||||
|
||||
// advance() still uses deterministic override if set, or advances from set initial/current
|
||||
policy.advance()
|
||||
assertEquals(1500L, policy.nextDelayMs())
|
||||
|
||||
// reset() retains deterministic override behavior or resets to overridden initial
|
||||
policy.reset()
|
||||
assertEquals(1500L, policy.nextDelayMs())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,555 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ReplayTrackerTest {
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
|
||||
|
||||
@Test
|
||||
fun `initial state has empty watermarks and no replay in flight`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = {},
|
||||
)
|
||||
|
||||
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||
assertFalse(tracker.isReplayInFlight())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `watermarks can be set read and cleared without cancelling jobs`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = {},
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
tracker.setSeqWatermark("s2", 25)
|
||||
|
||||
assertEquals(mapOf("s1" to 10, "s2" to 25), tracker.getSeqWatermarks())
|
||||
|
||||
tracker.clearSeqWatermarks()
|
||||
|
||||
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||
assertFalse(tracker.isReplayInFlight())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `triggerReplay is no-op when watermarks are empty`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
var fetchCalled = false
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = {
|
||||
fetchCalled = true
|
||||
SessionEventsSinceResult()
|
||||
},
|
||||
emitEvent = {},
|
||||
)
|
||||
|
||||
tracker.triggerReplay()
|
||||
|
||||
assertFalse(tracker.isReplayInFlight())
|
||||
testScope.advanceUntilIdle()
|
||||
assertFalse(fetchCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `triggerReplay arms hold synchronously and sets replay in flight before coroutine runs`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
val deferred = CompletableDeferred<SessionEventsSinceResult>()
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = { deferred.await() },
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 5)
|
||||
|
||||
// Arms hold synchronously
|
||||
tracker.triggerReplay()
|
||||
|
||||
assertTrue(tracker.isReplayInFlight())
|
||||
|
||||
// Calling triggerReplay again while in flight is ignored
|
||||
tracker.triggerReplay()
|
||||
assertTrue(tracker.isReplayInFlight())
|
||||
|
||||
// A live event arriving now for s1 must be parked into hold without advancing watermark
|
||||
val liveMsg = WsEvent.MessageToken(token = "live", sessionId = "s1")
|
||||
tracker.acceptLiveEvent("s1", 8, liveMsg)
|
||||
|
||||
// Watermark has NOT advanced to 8 because replay is in flight and event is held
|
||||
assertEquals(5, tracker.getSeqWatermarks()["s1"])
|
||||
assertTrue(emitted.isEmpty())
|
||||
|
||||
// Complete replay
|
||||
deferred.complete(SessionEventsSinceResult())
|
||||
testScope.advanceUntilIdle()
|
||||
|
||||
// Replay completed, held event released and emitted, watermark advanced
|
||||
assertFalse(tracker.isReplayInFlight())
|
||||
assertEquals(listOf(liveMsg), emitted)
|
||||
assertEquals(8, tracker.getSeqWatermarks()["s1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isDuplicate drops equal or older seq and accepts newer seq`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = {},
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
|
||||
// Same seq: duplicate
|
||||
assertTrue(tracker.isDuplicate("s1", 10))
|
||||
// Older seq: duplicate
|
||||
assertTrue(tracker.isDuplicate("s1", 5))
|
||||
// Newer seq: not duplicate
|
||||
assertFalse(tracker.isDuplicate("s1", 11))
|
||||
// Unknown session defaults to seq 0
|
||||
assertFalse(tracker.isDuplicate("s2", 1))
|
||||
assertTrue(tracker.isDuplicate("s2", 0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptLiveEvent without in-flight replay emits event and advances watermark`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
val event = WsEvent.MessageToken(token = "tok", sessionId = "s1")
|
||||
|
||||
// Duplicate seq: dropped
|
||||
tracker.acceptLiveEvent("s1", 10, event)
|
||||
assertTrue(emitted.isEmpty())
|
||||
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||
|
||||
// Newer seq: emitted and updates watermark
|
||||
tracker.acceptLiveEvent("s1", 12, event)
|
||||
assertEquals(listOf(event), emitted)
|
||||
assertEquals(12, tracker.getSeqWatermarks()["s1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay events are emitted in provided order and advance watermarks`() =
|
||||
runTest {
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
val event1 =
|
||||
jsonElement(
|
||||
"""{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"a"}}""",
|
||||
)
|
||||
val event2 =
|
||||
jsonElement(
|
||||
"""{"type":"message.token","session_id":"s1","seq":7,"payload":{"text":"b"}}""",
|
||||
)
|
||||
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = {
|
||||
SessionEventsSinceResult(
|
||||
events = listOf(event1, event2),
|
||||
)
|
||||
},
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 5)
|
||||
tracker.fetchReplayForTest()
|
||||
|
||||
assertEquals(2, emitted.size)
|
||||
assertEquals("a", (emitted[0] as WsEvent.MessageToken).token)
|
||||
assertEquals("b", (emitted[1] as WsEvent.MessageToken).token)
|
||||
assertEquals(7, tracker.getSeqWatermarks()["s1"])
|
||||
assertFalse(tracker.isReplayInFlight())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay events with seq at or below watermark are dropped`() =
|
||||
runTest {
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
val oldEvent =
|
||||
jsonElement(
|
||||
"""{"type":"message.token","session_id":"s1","seq":5,"payload":{"text":"old"}}""",
|
||||
)
|
||||
val newEvent =
|
||||
jsonElement(
|
||||
"""{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"new"}}""",
|
||||
)
|
||||
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = {
|
||||
SessionEventsSinceResult(
|
||||
events = listOf(oldEvent, newEvent),
|
||||
)
|
||||
},
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 5)
|
||||
tracker.fetchReplayForTest()
|
||||
|
||||
assertEquals(1, emitted.size)
|
||||
assertEquals("new", (emitted[0] as WsEvent.MessageToken).token)
|
||||
assertEquals(6, tracker.getSeqWatermarks()["s1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `held live duplicates covered by replay are dropped and fresh held follow`() =
|
||||
runTest {
|
||||
val deferred = CompletableDeferred<SessionEventsSinceResult>()
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = { deferred.await() },
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 5)
|
||||
tracker.triggerReplay()
|
||||
|
||||
// Live event 6 arrives while replay in flight
|
||||
val live6 = WsEvent.MessageToken(token = "live6", sessionId = "s1")
|
||||
val live7 = WsEvent.MessageToken(token = "live7", sessionId = "s1")
|
||||
tracker.acceptLiveEvent("s1", 6, live6)
|
||||
tracker.acceptLiveEvent("s1", 7, live7)
|
||||
|
||||
// Replay also returns event 6
|
||||
val replay6 =
|
||||
jsonElement(
|
||||
"""{"type":"message.token","session_id":"s1","seq":6,"payload":{"text":"replay6"}}""",
|
||||
)
|
||||
deferred.complete(
|
||||
SessionEventsSinceResult(
|
||||
events = listOf(replay6),
|
||||
),
|
||||
)
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
// Replay 6 is emitted first, advancing watermark to 6.
|
||||
// Held live 6 (seq 6 <= 6) is dropped as duplicate.
|
||||
// Held live 7 (seq 7 > 6) is emitted and advances watermark to 7.
|
||||
assertEquals(2, emitted.size)
|
||||
assertEquals("replay6", (emitted[0] as WsEvent.MessageToken).token)
|
||||
assertEquals("live7", (emitted[1] as WsEvent.MessageToken).token)
|
||||
assertEquals(7, tracker.getSeqWatermarks()["s1"])
|
||||
assertFalse(tracker.isReplayInFlight())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `epoch change clears all watermarks updates epoch and requests transcript resync`() =
|
||||
runTest {
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = {
|
||||
SessionEventsSinceResult(
|
||||
epoch = "epoch-2",
|
||||
latestSeq = 50,
|
||||
)
|
||||
},
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setReplayEpochForTest("epoch-1")
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
tracker.setSeqWatermark("s2", 20)
|
||||
|
||||
tracker.fetchReplayForTest()
|
||||
|
||||
// Watermarks wiped
|
||||
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||
assertEquals(1, emitted.size)
|
||||
assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty or null epoch in result does not trigger epoch change`() =
|
||||
runTest {
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = {
|
||||
SessionEventsSinceResult(
|
||||
epoch = "",
|
||||
events = emptyList(),
|
||||
)
|
||||
},
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setReplayEpochForTest("epoch-1")
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
|
||||
tracker.fetchReplayForTest()
|
||||
|
||||
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||
assertTrue(emitted.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `truncated resync fast-forwards watermark to latestSeq when epoch is same`() =
|
||||
runTest {
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = {
|
||||
SessionEventsSinceResult(
|
||||
truncated = true,
|
||||
latestSeq = 100,
|
||||
epoch = "epoch-1",
|
||||
)
|
||||
},
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setReplayEpochForTest("epoch-1")
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
|
||||
tracker.fetchReplayForTest()
|
||||
|
||||
// Fast-forwards to latestSeq and emits TranscriptResyncRequired
|
||||
assertEquals(100, tracker.getSeqWatermarks()["s1"])
|
||||
assertEquals(listOf(WsEvent.TranscriptResyncRequired("s1")), emitted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `replay failure triggers resync invokes onReplayFailure and releases held events`() =
|
||||
runTest {
|
||||
val deferred = CompletableDeferred<SessionEventsSinceResult>()
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
val failedSessions = mutableListOf<Pair<String, Exception>>()
|
||||
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = {
|
||||
deferred.await()
|
||||
},
|
||||
emitEvent = { emitted.add(it) },
|
||||
onReplayFailure = { sid, ex -> failedSessions.add(sid to ex) },
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
tracker.triggerReplay()
|
||||
|
||||
// Park a live event while waiting
|
||||
val liveEvent = WsEvent.MessageToken(token = "held", sessionId = "s1")
|
||||
tracker.acceptLiveEvent("s1", 15, liveEvent)
|
||||
|
||||
// Fail replay
|
||||
val testException = IllegalStateException("network error")
|
||||
deferred.completeExceptionally(testException)
|
||||
|
||||
testScheduler.advanceUntilIdle()
|
||||
|
||||
// Failure emitted TranscriptResyncRequired, reported to callback, and released held events
|
||||
assertEquals(1, failedSessions.size)
|
||||
assertEquals("s1", failedSessions[0].first)
|
||||
assertTrue(failedSessions[0].second is IllegalStateException)
|
||||
assertEquals("network error", failedSessions[0].second.message)
|
||||
|
||||
assertEquals(2, emitted.size)
|
||||
assertEquals(WsEvent.TranscriptResyncRequired("s1"), emitted[0])
|
||||
assertEquals(liveEvent, emitted[1])
|
||||
assertEquals(15, tracker.getSeqWatermarks()["s1"])
|
||||
assertFalse(tracker.isReplayInFlight())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `MessageComplete event uses resolveStoredSessionId injection`() =
|
||||
runTest {
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
val msgCompleteJson =
|
||||
jsonElement(
|
||||
"""{"type":"message.complete","session_id":"wire-sid","payload":{"text":"done"}}""",
|
||||
)
|
||||
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = {
|
||||
SessionEventsSinceResult(
|
||||
events = listOf(msgCompleteJson),
|
||||
)
|
||||
},
|
||||
emitEvent = { emitted.add(it) },
|
||||
resolveStoredSessionId = { wireSid -> "resolved-$wireSid" },
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("wire-sid", 5)
|
||||
tracker.fetchReplayForTest()
|
||||
|
||||
assertEquals(1, emitted.size)
|
||||
val complete = emitted[0] as WsEvent.MessageComplete
|
||||
assertEquals("done", complete.text)
|
||||
assertEquals("wire-sid", complete.sessionId)
|
||||
assertEquals("resolved-wire-sid", complete.storedSessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `updateEpoch clears watermarks when epoch changes`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = {},
|
||||
)
|
||||
|
||||
tracker.setReplayEpochForTest("epoch-1")
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
|
||||
// Same epoch does not clear
|
||||
tracker.updateEpoch("epoch-1")
|
||||
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||
|
||||
// Empty / null does not clear
|
||||
tracker.updateEpoch(null)
|
||||
tracker.updateEpoch("")
|
||||
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||
|
||||
// Different epoch clears watermarks
|
||||
tracker.updateEpoch("epoch-2")
|
||||
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clear cancels in-flight job and resets all state`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
val deferred = CompletableDeferred<SessionEventsSinceResult>()
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = { deferred.await() },
|
||||
emitEvent = {},
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
tracker.triggerReplay()
|
||||
assertTrue(tracker.isReplayInFlight())
|
||||
|
||||
tracker.clear()
|
||||
|
||||
assertFalse(tracker.isReplayInFlight())
|
||||
assertTrue(tracker.getSeqWatermarks().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptLiveEvent with eventFactory drops duplicate seq without invoking factory or emitting`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
var factoryInvoked = false
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
|
||||
// Duplicate seq <= watermark must not invoke factory nor emit
|
||||
tracker.acceptLiveEvent(sessionId = "s1", seq = 10) {
|
||||
factoryInvoked = true
|
||||
WsEvent.MessageToken(token = "duplicate", sessionId = "s1")
|
||||
}
|
||||
|
||||
assertFalse(factoryInvoked)
|
||||
assertTrue(emitted.isEmpty())
|
||||
assertEquals(10, tracker.getSeqWatermarks()["s1"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `acceptLiveEvent with eventFactory invokes factory once and preserves single-check ordering`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
val testScope = TestScope(testDispatcher)
|
||||
val emitted = mutableListOf<WsEvent>()
|
||||
val invocationCount = AtomicInteger(0)
|
||||
val tracker =
|
||||
ReplayTracker(
|
||||
scope = testScope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { emitted.add(it) },
|
||||
)
|
||||
|
||||
tracker.setSeqWatermark("s1", 10)
|
||||
val expectedEvent = WsEvent.MessageToken(token = "fresh", sessionId = "s1")
|
||||
|
||||
// Factory intentionally injects an interleaved watermark advance (e.g. simulating race or side effect).
|
||||
// Under single-check ordering, the initial pre-parse check (seq 11 > 10) succeeds, calls factory exactly once,
|
||||
// and proceeds to emit and advance watermark without silently dropping due to an unexpected second check.
|
||||
tracker.acceptLiveEvent(sessionId = "s1", seq = 11) {
|
||||
invocationCount.incrementAndGet()
|
||||
tracker.setSeqWatermark("s1", 99)
|
||||
expectedEvent
|
||||
}
|
||||
|
||||
assertEquals(1, invocationCount.get())
|
||||
assertEquals(listOf(expectedEvent), emitted)
|
||||
assertEquals(11, tracker.getSeqWatermarks()["s1"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,790 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.async
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.int
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assert.fail
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RpcChannelTest {
|
||||
private val testJson =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = false
|
||||
}
|
||||
|
||||
@Serializable
|
||||
private data class SampleRequestParams(
|
||||
val message: String,
|
||||
val count: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class SampleResult(
|
||||
val status: String,
|
||||
val total: Int,
|
||||
)
|
||||
|
||||
private val sampleMethod =
|
||||
RpcMethod("test.sample", SampleRequestParams.serializer(), SampleResult.serializer())
|
||||
|
||||
private class FakeGateway(
|
||||
scope: CoroutineScope,
|
||||
json: Json,
|
||||
outboundLock: Any = Any(),
|
||||
val onResultCallback: (String) -> Unit = {},
|
||||
val onErrorCallback: (String) -> Unit = {},
|
||||
resolveStoredSessionId: (String?) -> String? = { it },
|
||||
val logParseFailureCallback: (Exception) -> Unit = {},
|
||||
decorateParams: (String, Map<String, Any>) -> Map<String, Any> = { _, p -> p },
|
||||
) {
|
||||
val sentRequests = mutableListOf<SentRequestRecord>()
|
||||
val sentFrames = mutableListOf<String>()
|
||||
val removedQueuedIds = mutableListOf<String>()
|
||||
val rejectedLogs = mutableListOf<Pair<String, String>>()
|
||||
var idleCheckCount = 0
|
||||
var sendFrameReturnValue = true
|
||||
|
||||
var immediateResolveOnSend: ((id: String, onSent: ((String) -> Unit)?) -> Unit)? = null
|
||||
|
||||
val channel: RpcChannel =
|
||||
RpcChannel(
|
||||
scope = scope,
|
||||
json = json,
|
||||
sendRequest = { method, params, onSent ->
|
||||
val id = "req-${sentRequests.size + 1}"
|
||||
sentRequests.add(SentRequestRecord(id, method, params))
|
||||
onSent?.invoke(id)
|
||||
immediateResolveOnSend?.invoke(id, onSent)
|
||||
id
|
||||
},
|
||||
sendFrame = { frame ->
|
||||
sentFrames.add(frame)
|
||||
sendFrameReturnValue
|
||||
},
|
||||
removeQueuedMessage = { id ->
|
||||
removedQueuedIds.add(id)
|
||||
},
|
||||
onIdleCheck = {
|
||||
idleCheckCount++
|
||||
},
|
||||
logRejected = { method, id ->
|
||||
rejectedLogs.add(method to id)
|
||||
},
|
||||
outboundLock = outboundLock,
|
||||
onResult = onResultCallback,
|
||||
onError = onErrorCallback,
|
||||
resolveStoredSessionId = resolveStoredSessionId,
|
||||
logParseFailure = logParseFailureCallback,
|
||||
decorateParams = decorateParams,
|
||||
)
|
||||
|
||||
data class SentRequestRecord(
|
||||
val id: String,
|
||||
val method: String,
|
||||
val params: Map<String, Any>,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nextIdProducesIncreasingNonBlankStrings() {
|
||||
val gateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
|
||||
val firstId = gateway.channel.nextId()
|
||||
val secondId = gateway.channel.nextId()
|
||||
|
||||
assertEquals("1", firstId)
|
||||
assertTrue(firstId.isNotBlank())
|
||||
assertTrue(secondId.isNotBlank())
|
||||
val firstNum = firstId.toLong()
|
||||
val secondNum = secondId.toLong()
|
||||
assertTrue(secondNum > firstNum)
|
||||
assertEquals(firstNum + 1L, secondNum)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestSuccessResolvesDeferredAndCleansUpInOrder() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
val deferred = gateway.channel.request("user.ping", mapOf("key" to "value"))
|
||||
val id = gateway.sentRequests.first().id
|
||||
|
||||
assertTrue(gateway.channel.hasPendingCalls())
|
||||
assertTrue(gateway.channel.pendingCallIdsForTest().contains(id))
|
||||
assertNotNull(gateway.channel.pendingCallTimeoutForTest(id))
|
||||
|
||||
val dummyResult = JsonPrimitive("pong")
|
||||
gateway.channel.resolvePending(id, dummyResult, null)
|
||||
|
||||
val resolved = deferred.await()
|
||||
assertEquals(dummyResult, resolved)
|
||||
assertFalse(gateway.channel.hasPendingCalls())
|
||||
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||
assertEquals(1, gateway.idleCheckCount)
|
||||
assertNull(gateway.channel.pendingCallTimeoutForTest(id))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestErrorResolvesWithHermesRpcExceptionAndErrorData() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
val deferred = gateway.channel.request("user.fail")
|
||||
val id = gateway.sentRequests.first().id
|
||||
|
||||
val errorData = buildJsonObject { put("reason", "auth_failed") }
|
||||
val rpcError = JsonRpcError(code = 401, message = "Unauthorized", data = errorData)
|
||||
|
||||
gateway.channel.resolvePending(id, null, rpcError)
|
||||
|
||||
try {
|
||||
deferred.await()
|
||||
fail("Expected HermesRpcException")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals("Unauthorized", e.message)
|
||||
assertEquals(401, e.code)
|
||||
assertEquals(errorData, e.data)
|
||||
}
|
||||
|
||||
assertFalse(gateway.channel.hasPendingCalls())
|
||||
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||
assertEquals(1, gateway.idleCheckCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requestTimeoutFailsWithExactMessageAndCodeMinusOne() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
val timeoutMs = 5_000L
|
||||
val deferred = gateway.channel.request("agent.turn", timeoutMs = timeoutMs)
|
||||
val id = gateway.sentRequests.first().id
|
||||
|
||||
runCurrent()
|
||||
advanceTimeBy(4_999L)
|
||||
runCurrent()
|
||||
assertTrue(gateway.channel.hasPendingCalls())
|
||||
assertFalse(deferred.isCompleted)
|
||||
|
||||
advanceTimeBy(1L)
|
||||
runCurrent()
|
||||
|
||||
assertTrue(deferred.isCompleted)
|
||||
assertFalse(gateway.channel.hasPendingCalls())
|
||||
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||
assertEquals(1, gateway.idleCheckCount)
|
||||
|
||||
try {
|
||||
deferred.await()
|
||||
fail("Expected HermesRpcException on timeout")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals("Request timed out: agent.turn", e.message)
|
||||
assertEquals(-1, e.code)
|
||||
assertNull(e.data)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deferredCancellationCancelsTimerRemovesQueuedAndTriggersIdle() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
val deferred = gateway.channel.request("slow.method", timeoutMs = 60_000L)
|
||||
val id = gateway.sentRequests.first().id
|
||||
|
||||
val timeoutJob = gateway.channel.pendingCallTimeoutForTest(id)
|
||||
assertNotNull(timeoutJob)
|
||||
assertFalse(timeoutJob!!.isCancelled)
|
||||
|
||||
deferred.cancel()
|
||||
runCurrent()
|
||||
|
||||
assertTrue(deferred.isCancelled)
|
||||
assertFalse(gateway.channel.hasPendingCalls())
|
||||
assertTrue(timeoutJob.isCancelled)
|
||||
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||
assertEquals(1, gateway.idleCheckCount)
|
||||
|
||||
// Advancing past original timeout should do nothing further
|
||||
advanceTimeBy(70_000L)
|
||||
runCurrent()
|
||||
assertEquals(1, gateway.idleCheckCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun suppressionFlagReflectedInIsErrorEventSuppressed() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
gateway.channel.request("normal.call", suppressErrorEvent = false)
|
||||
val normalId = gateway.sentRequests[0].id
|
||||
|
||||
gateway.channel.request("silent.call", suppressErrorEvent = true)
|
||||
val silentId = gateway.sentRequests[1].id
|
||||
|
||||
assertFalse(gateway.channel.isErrorEventSuppressed(normalId))
|
||||
assertTrue(gateway.channel.isErrorEventSuppressed(silentId))
|
||||
assertFalse(gateway.channel.isErrorEventSuppressed("non-existent-id"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectAllPendingFailsAllCallsWithProvidedExceptionAndNoIdleCheck() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
val deferred1 = gateway.channel.request("method.one")
|
||||
val deferred2 = gateway.channel.request("method.two")
|
||||
val id1 = gateway.sentRequests[0].id
|
||||
val id2 = gateway.sentRequests[1].id
|
||||
|
||||
val customException = HermesWsClient.HermesRpcException("Socket terminated abruptly", code = 1006)
|
||||
|
||||
gateway.channel.rejectAllPending(customException)
|
||||
|
||||
assertFalse(gateway.channel.hasPendingCalls())
|
||||
// Verified contract: rejectAllPending performs NO onIdleCheck
|
||||
assertEquals(0, gateway.idleCheckCount)
|
||||
assertEquals(setOf(id1, id2), gateway.removedQueuedIds.toSet())
|
||||
assertEquals(setOf("method.one" to id1, "method.two" to id2), gateway.rejectedLogs.toSet())
|
||||
|
||||
try {
|
||||
deferred1.await()
|
||||
fail("deferred1 should have failed")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertSame(customException, e)
|
||||
}
|
||||
|
||||
try {
|
||||
deferred2.await()
|
||||
fail("deferred2 should have failed")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertSame(customException, e)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectAllPendingDefaultExceptionMatchesExpectedMessage() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
val deferred = gateway.channel.request("some.call")
|
||||
|
||||
gateway.channel.rejectAllPending()
|
||||
|
||||
try {
|
||||
deferred.await()
|
||||
fail("Expected default exception")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals("Connection lost — request cancelled", e.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun immediateResolutionDuringOnSentHandledSafely() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
gateway.immediateResolveOnSend = { id, _ ->
|
||||
gateway.channel.resolvePending(id, "instant-result", null)
|
||||
}
|
||||
|
||||
val deferred = gateway.channel.request("instant.call")
|
||||
val id = gateway.sentRequests.first().id
|
||||
|
||||
assertTrue(deferred.isCompleted)
|
||||
assertEquals("instant-result", deferred.await())
|
||||
assertFalse(gateway.channel.hasPendingCalls())
|
||||
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||
assertEquals(1, gateway.idleCheckCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallSerializesParamsOmittingDefaultsAndDecodesResult() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
|
||||
val job =
|
||||
async {
|
||||
gateway.channel.call(
|
||||
sampleMethod,
|
||||
SampleRequestParams(message = "hello", count = null),
|
||||
)
|
||||
}
|
||||
|
||||
runCurrent()
|
||||
assertEquals(1, gateway.sentRequests.size)
|
||||
val sent = gateway.sentRequests.first()
|
||||
assertEquals("test.sample", sent.method)
|
||||
// encodeDefaults = false means count (null) is omitted from params
|
||||
val encodedParams =
|
||||
gateway.channel.encodeParams(
|
||||
sampleMethod,
|
||||
SampleRequestParams(message = "hello", count = null),
|
||||
)
|
||||
assertFalse(encodedParams.containsKey("count"))
|
||||
assertTrue(encodedParams.containsKey("message"))
|
||||
|
||||
// Resolve pending with raw json representation
|
||||
val rawResult =
|
||||
buildJsonObject {
|
||||
put("status", "ok")
|
||||
put("total", 42)
|
||||
put("extra_ignored_key", "bonus")
|
||||
}
|
||||
gateway.channel.resolvePending(sent.id, rawResult, null)
|
||||
|
||||
val result = job.await()
|
||||
assertEquals("ok", result.status)
|
||||
assertEquals(42, result.total)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun typedCallCancellationPropagatesAndCancelsDeferred() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
|
||||
val job =
|
||||
launch {
|
||||
gateway.channel.call(
|
||||
sampleMethod,
|
||||
SampleRequestParams(message = "cancel-me"),
|
||||
)
|
||||
}
|
||||
|
||||
runCurrent()
|
||||
val id = gateway.sentRequests.first().id
|
||||
assertTrue(gateway.channel.hasPendingCalls())
|
||||
|
||||
job.cancel()
|
||||
runCurrent()
|
||||
|
||||
assertFalse(gateway.channel.hasPendingCalls())
|
||||
assertEquals(listOf(id), gateway.removedQueuedIds)
|
||||
assertEquals(1, gateway.idleCheckCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rawJsonNumericFidelityPreservedIntoResolve() =
|
||||
runTest {
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
|
||||
val deferred =
|
||||
gateway.channel.requestTyped(
|
||||
sampleMethod,
|
||||
SampleRequestParams(message = "precision"),
|
||||
)
|
||||
val id = gateway.sentRequests.first().id
|
||||
|
||||
// Map with integer-value double, e.g. 177.0 -> preserves int semantics in toJsonElement
|
||||
val mapResult = mapOf("status" to "precise", "total" to 177.0)
|
||||
gateway.channel.resolvePending(id, mapResult, null)
|
||||
|
||||
val raw = deferred.await()
|
||||
val jsonElement = raw.toJsonElement() as JsonObject
|
||||
assertEquals(177, jsonElement["total"]?.jsonPrimitive?.int)
|
||||
|
||||
// Direct decode with injected channel json to verify round-trip
|
||||
val decoded = testJson.decodeFromJsonElement(sampleMethod.result, jsonElement)
|
||||
assertEquals(177, decoded.total)
|
||||
assertEquals("precise", decoded.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun respondToServerRequestFormatsExactJsonRpcResponseAndPropagatesSink() {
|
||||
val gateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
|
||||
val serverReqId = "srv-999"
|
||||
val payload = buildJsonObject { put("pong", true) }
|
||||
|
||||
gateway.sendFrameReturnValue = true
|
||||
val result = gateway.channel.respondToServerRequest(serverReqId, payload)
|
||||
assertTrue(result)
|
||||
assertEquals(1, gateway.sentFrames.size)
|
||||
|
||||
val frame = testJson.decodeFromString<JsonObject>(gateway.sentFrames.first())
|
||||
assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content)
|
||||
assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content)
|
||||
assertEquals(payload, frame["result"])
|
||||
assertFalse(frame.containsKey("method"))
|
||||
assertFalse(frame.containsKey("params"))
|
||||
|
||||
// Blank id returns false immediately without sending frame
|
||||
val blankResult = gateway.channel.respondToServerRequest(" ", payload)
|
||||
assertFalse(blankResult)
|
||||
assertEquals(1, gateway.sentFrames.size)
|
||||
|
||||
// Sink returning false is propagated
|
||||
gateway.sendFrameReturnValue = false
|
||||
val falseResult = gateway.channel.respondToServerRequest("srv-1000", payload)
|
||||
assertFalse(falseResult)
|
||||
assertEquals(2, gateway.sentFrames.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun respondToServerRequestErrorFormatsExactJsonRpcErrorAndPropagatesSink() {
|
||||
val gateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
|
||||
val serverReqId = "srv-888"
|
||||
|
||||
gateway.sendFrameReturnValue = true
|
||||
val result = gateway.channel.respondToServerRequestError(serverReqId, -32601, "Method not found")
|
||||
assertTrue(result)
|
||||
assertEquals(1, gateway.sentFrames.size)
|
||||
|
||||
val frame = testJson.decodeFromString<JsonObject>(gateway.sentFrames.first())
|
||||
assertEquals("2.0", frame["jsonrpc"]?.jsonPrimitive?.content)
|
||||
assertEquals(serverReqId, frame["id"]?.jsonPrimitive?.content)
|
||||
assertFalse(frame.containsKey("result"))
|
||||
val errorObj = frame["error"] as JsonObject
|
||||
assertEquals(-32601, errorObj["code"]?.jsonPrimitive?.int)
|
||||
assertEquals("Method not found", errorObj["message"]?.jsonPrimitive?.content)
|
||||
|
||||
// Blank id returns false without sending frame
|
||||
assertFalse(gateway.channel.respondToServerRequestError("", -1, "Bad"))
|
||||
assertEquals(1, gateway.sentFrames.size)
|
||||
|
||||
// Sink returning false is propagated
|
||||
gateway.sendFrameReturnValue = false
|
||||
val falseResult = gateway.channel.respondToServerRequestError("srv-889", -32000, "Error")
|
||||
assertFalse(falseResult)
|
||||
assertEquals(2, gateway.sentFrames.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createRequestDecoratesOnceAllocatesIdAndEncodesOmittingDefaults() {
|
||||
var decorateCount = 0
|
||||
val gateway =
|
||||
FakeGateway(
|
||||
scope = CoroutineScope(CompletableDeferred<Unit>()),
|
||||
json = testJson,
|
||||
decorateParams = { method, params ->
|
||||
decorateCount++
|
||||
params + ("decoratedMethod" to method)
|
||||
},
|
||||
)
|
||||
|
||||
val request = gateway.channel.createRequest("test.action", mapOf("key" to "value"))
|
||||
assertEquals("1", request.id)
|
||||
assertEquals("test.action", request.method)
|
||||
assertEquals(1, decorateCount)
|
||||
assertEquals(JsonPrimitive("value"), request.params["key"])
|
||||
assertEquals(JsonPrimitive("test.action"), request.params["decoratedMethod"])
|
||||
|
||||
val encoded = gateway.channel.encodeRequest(request)
|
||||
// Injected json with encodeDefaults = false omits default jsonrpc ("2.0")
|
||||
assertFalse(encoded.contains("\"jsonrpc\""))
|
||||
assertTrue(encoded.contains("\"id\":\"1\"") || encoded.contains("\"id\": \"1\""))
|
||||
assertTrue(encoded.contains("\"method\":\"test.action\"") || encoded.contains("\"method\": \"test.action\""))
|
||||
|
||||
// An empty params map is omitted by encodeRequest under encodeDefaults = false
|
||||
val plainGateway = FakeGateway(CoroutineScope(CompletableDeferred<Unit>()), testJson)
|
||||
val emptyParamsReq = plainGateway.channel.createRequest("test.empty", emptyMap())
|
||||
val encodedEmpty = plainGateway.channel.encodeRequest(emptyParamsReq)
|
||||
assertFalse(encodedEmpty.contains("\"params\""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleIncomingFrameOrderRawJsonResolvedBeforeEventEmittedAndDoubleOnResult() =
|
||||
runTest {
|
||||
val onResultCalls = mutableListOf<String>()
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val gateway =
|
||||
FakeGateway(
|
||||
scope = this,
|
||||
json = testJson,
|
||||
onResultCallback = { onResultCalls.add(it) },
|
||||
)
|
||||
val replayTracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { events.add(it) },
|
||||
)
|
||||
|
||||
val deferred = gateway.channel.request("client.calc")
|
||||
val id = gateway.sentRequests.first().id
|
||||
|
||||
var rawResolvedAtEventTime: Boolean? = null
|
||||
var onResultCallsAtEventTime = 0
|
||||
val emitSink: (WsEvent) -> Unit = { event ->
|
||||
events.add(event)
|
||||
rawResolvedAtEventTime = deferred.isCompleted
|
||||
onResultCallsAtEventTime = onResultCalls.size
|
||||
}
|
||||
|
||||
val incomingFrame = """{"jsonrpc":"2.0","id":"$id","result":{"ans":42.0}}"""
|
||||
gateway.channel.handleIncomingFrame(incomingFrame, replayTracker, emitSink)
|
||||
|
||||
// onResult is invoked twice baseline: once before raw resolve, and again before parsed RpcResult resolution
|
||||
assertEquals(2, onResultCalls.size)
|
||||
assertEquals(listOf(id, id), onResultCalls)
|
||||
assertEquals(2, onResultCallsAtEventTime)
|
||||
|
||||
// Raw deferred was settled BEFORE emitEvent was called
|
||||
assertEquals(true, rawResolvedAtEventTime)
|
||||
assertTrue(deferred.isCompleted)
|
||||
|
||||
// Numeric fidelity preserved on result
|
||||
val rawResult = deferred.await()
|
||||
assertTrue(rawResult is JsonObject)
|
||||
val rawObj = rawResult as JsonObject
|
||||
assertEquals(JsonPrimitive(42.0), rawObj["ans"])
|
||||
|
||||
// Event emitted was RpcResult
|
||||
assertEquals(1, events.size)
|
||||
val rpcResult = events.first() as WsEvent.RpcResult
|
||||
assertEquals(id, rpcResult.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleIncomingFrameRpcErrorInvokesCallbackAndRespectsSuppression() =
|
||||
runTest {
|
||||
val onErrorCalls = mutableListOf<String>()
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val gateway =
|
||||
FakeGateway(
|
||||
scope = this,
|
||||
json = testJson,
|
||||
onErrorCallback = { onErrorCalls.add(it) },
|
||||
)
|
||||
val replayTracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { events.add(it) },
|
||||
)
|
||||
|
||||
// 1. Normal unsuppressed error
|
||||
val normalDeferred = gateway.channel.request("call.normal", suppressErrorEvent = false)
|
||||
val normalId = gateway.sentRequests[0].id
|
||||
val errorFrame1 =
|
||||
"""{"jsonrpc":"2.0","id":"$normalId","error":{"code":-32603,"message":"Internal error"}}"""
|
||||
gateway.channel.handleIncomingFrame(errorFrame1, replayTracker) { events.add(it) }
|
||||
|
||||
assertEquals(listOf(normalId), onErrorCalls)
|
||||
assertTrue(normalDeferred.isCompleted)
|
||||
try {
|
||||
normalDeferred.await()
|
||||
fail("Expected HermesRpcException")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals(-32603, e.code)
|
||||
assertEquals("Internal error", e.message)
|
||||
}
|
||||
assertEquals(1, events.size)
|
||||
assertTrue(events.first() is WsEvent.RpcError)
|
||||
assertEquals(normalId, (events.first() as WsEvent.RpcError).id)
|
||||
|
||||
// 2. Suppressed error resolves deferred but emits NONE
|
||||
val silentDeferred = gateway.channel.request("call.silent", suppressErrorEvent = true)
|
||||
val silentId = gateway.sentRequests[1].id
|
||||
val errorFrame2 =
|
||||
"""{"jsonrpc":"2.0","id":"$silentId","error":{"code":404,"message":"Not found"}}"""
|
||||
gateway.channel.handleIncomingFrame(errorFrame2, replayTracker) { events.add(it) }
|
||||
|
||||
assertEquals(listOf(normalId, silentId), onErrorCalls)
|
||||
assertTrue(silentDeferred.isCompleted)
|
||||
try {
|
||||
silentDeferred.await()
|
||||
fail("Expected HermesRpcException on silent deferred")
|
||||
} catch (e: HermesWsClient.HermesRpcException) {
|
||||
assertEquals(404, e.code)
|
||||
}
|
||||
// Event list size remains 1 (no RpcError event emitted for suppressed call)
|
||||
assertEquals(1, events.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleIncomingFrameCapabilitySuppressionHidesEventUntilCleared() =
|
||||
runTest {
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
val replayTracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { events.add(it) },
|
||||
)
|
||||
|
||||
val capId = "cap-42"
|
||||
gateway.channel.registerCapabilityRequest(capId)
|
||||
|
||||
val capResponseFrame = """{"jsonrpc":"2.0","id":"$capId","result":{"supported":true}}"""
|
||||
gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) }
|
||||
|
||||
// Registered capability response is hidden from events
|
||||
assertTrue(events.isEmpty())
|
||||
|
||||
// After clearing capabilities, subsequent responses with that ID are visible
|
||||
gateway.channel.registerCapabilityRequest(capId)
|
||||
gateway.channel.clearCapabilityRequests()
|
||||
gateway.channel.handleIncomingFrame(capResponseFrame, replayTracker) { events.add(it) }
|
||||
|
||||
assertEquals(1, events.size)
|
||||
val rpcResult = events.first() as WsEvent.RpcResult
|
||||
assertEquals(capId, rpcResult.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleIncomingFrameMalformedJsonInvokesParseFailureAndEmitsUnknown() =
|
||||
runTest {
|
||||
val parseFailures = mutableListOf<Exception>()
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val gateway =
|
||||
FakeGateway(
|
||||
scope = this,
|
||||
json = testJson,
|
||||
logParseFailureCallback = { parseFailures.add(it) },
|
||||
)
|
||||
val replayTracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { events.add(it) },
|
||||
)
|
||||
|
||||
val malformed = "{ invalid json frame ::"
|
||||
gateway.channel.handleIncomingFrame(malformed, replayTracker) { events.add(it) }
|
||||
|
||||
assertEquals(1, parseFailures.size)
|
||||
assertEquals(1, events.size)
|
||||
val unknown = events.first() as WsEvent.Unknown
|
||||
assertEquals(malformed, unknown.raw)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleIncomingFrameServerRequestClassifiedCorrectlyOverRpcResult() =
|
||||
runTest {
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
val replayTracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { events.add(it) },
|
||||
)
|
||||
|
||||
// Normal srq frame with id + method should emit ServerRequest, not RpcResult
|
||||
val srqFrame = """{"jsonrpc":"2.0","id":"srq-101","method":"clarify","params":{"text":"Confirm?"}}"""
|
||||
gateway.channel.handleIncomingFrame(srqFrame, replayTracker) { events.add(it) }
|
||||
|
||||
assertEquals(1, events.size)
|
||||
val event = events.first()
|
||||
assertTrue("Expected ServerRequest, got: $event", event is WsEvent.ServerRequest)
|
||||
val srq = event as WsEvent.ServerRequest
|
||||
assertEquals("srq-101", srq.id)
|
||||
assertEquals("clarify", srq.method)
|
||||
assertFalse(srq.replayed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleIncomingFrameSequencedNotificationDeduplicatesViaReplayTracker() =
|
||||
runTest {
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val gateway = FakeGateway(this, testJson)
|
||||
val replayTracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { events.add(it) },
|
||||
)
|
||||
|
||||
val seqFrame =
|
||||
"""{"method":"events","params":{"type":"message.token","session_id":"s1","seq":11,"payload":{"text":"fresh"}}}"""
|
||||
|
||||
// First delivery (fresh seq) emits once
|
||||
gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) }
|
||||
assertEquals(1, events.size)
|
||||
val tokenEvent = events.first() as WsEvent.MessageToken
|
||||
assertEquals("fresh", tokenEvent.token)
|
||||
assertEquals("s1", tokenEvent.sessionId)
|
||||
|
||||
// Duplicate delivery (same seq 11) is dropped, emits none
|
||||
gateway.channel.handleIncomingFrame(seqFrame, replayTracker) { events.add(it) }
|
||||
assertEquals(1, events.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleIncomingFrameSettlesRawResultBeforeReplayingOpenRequests() =
|
||||
runTest {
|
||||
val order = mutableListOf<String>()
|
||||
val gateway = FakeGateway(this, testJson, onResultCallback = { order.add("result:$it") })
|
||||
val replayTracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = {},
|
||||
)
|
||||
val deferred = gateway.channel.request("session.resume")
|
||||
val id = gateway.sentRequests.first().id
|
||||
deferred.invokeOnCompletion { order.add("resolved") }
|
||||
val frame =
|
||||
"""{"id":"$id","result":{"open_requests":[{"id":"srq-open","method":"clarify","params":{"text":"replayed"}}]}}"""
|
||||
|
||||
gateway.channel.handleIncomingFrame(frame, replayTracker) { event ->
|
||||
assertTrue("Raw deferred must settle before either event", deferred.isCompleted)
|
||||
when (event) {
|
||||
is WsEvent.ServerRequest -> {
|
||||
assertTrue(event.replayed)
|
||||
assertEquals("clarify", event.method)
|
||||
assertEquals("replayed", event.params["text"])
|
||||
order.add("server:${event.id}")
|
||||
}
|
||||
|
||||
is WsEvent.RpcResult -> {
|
||||
order.add("rpc:${event.id}")
|
||||
}
|
||||
|
||||
else -> {
|
||||
fail("Unexpected event: $event")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals(listOf("result:$id", "resolved", "server:srq-open", "result:$id", "rpc:$id"), order)
|
||||
assertTrue(deferred.await() is JsonObject)
|
||||
assertFalse(gateway.channel.hasPendingCalls())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun handleIncomingFrameDecoratesStoredSessionIdOnMessageComplete() =
|
||||
runTest {
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val gateway =
|
||||
FakeGateway(
|
||||
scope = this,
|
||||
json = testJson,
|
||||
resolveStoredSessionId = { sid -> "stored-$sid" },
|
||||
)
|
||||
val replayTracker =
|
||||
ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { events.add(it) },
|
||||
)
|
||||
|
||||
val completeFrame =
|
||||
"""{"method":"events","params":{"type":"message.complete","session_id":"active-session","payload":{"text":"done","reasoning":"reason"}}}"""
|
||||
gateway.channel.handleIncomingFrame(completeFrame, replayTracker) { events.add(it) }
|
||||
|
||||
assertEquals(1, events.size)
|
||||
val completeEvent = events.first() as WsEvent.MessageComplete
|
||||
assertEquals("done", completeEvent.text)
|
||||
assertEquals("reason", completeEvent.reasoning)
|
||||
assertEquals("active-session", completeEvent.sessionId)
|
||||
assertEquals("stored-active-session", completeEvent.storedSessionId)
|
||||
}
|
||||
}
|
||||
@@ -121,14 +121,12 @@ class SessionLiveStatusSourceTest {
|
||||
@Test
|
||||
fun `HermesSessionLiveStatusSource fetches and decodes snapshot via RPC`() =
|
||||
runBlocking {
|
||||
var capturedMethod: String? = null
|
||||
var capturedParams: Map<String, Any>? = null
|
||||
var rpcCalls = 0
|
||||
|
||||
val source =
|
||||
HermesSessionLiveStatusSource(
|
||||
rpcRequest = { method, params ->
|
||||
capturedMethod = method
|
||||
capturedParams = params
|
||||
rpcRequest = {
|
||||
rpcCalls++
|
||||
mapOf(
|
||||
"sessions" to
|
||||
listOf(
|
||||
@@ -146,8 +144,8 @@ class SessionLiveStatusSourceTest {
|
||||
|
||||
val snapshot = source.fetchActiveSessionsSnapshot()
|
||||
|
||||
assertEquals(WsMethods.SESSION_ACTIVE_LIST, capturedMethod)
|
||||
assertTrue(capturedParams!!.isEmpty())
|
||||
// Method name and empty params are pinned on the wire in HermesWsClientApprovalsTest.
|
||||
assertEquals(1, rpcCalls)
|
||||
assertNotNull(snapshot)
|
||||
assertEquals(SessionLiveStatus.WORKING, snapshot!!.statusByStoredId["st-1"])
|
||||
}
|
||||
@@ -157,7 +155,7 @@ class SessionLiveStatusSourceTest {
|
||||
runBlocking {
|
||||
val source =
|
||||
HermesSessionLiveStatusSource(
|
||||
rpcRequest = { _, _ -> throw RuntimeException("RPC error") },
|
||||
rpcRequest = { throw RuntimeException("RPC error") },
|
||||
eventsProvider = { MutableSharedFlow() },
|
||||
connectionStatusProvider = { MutableStateFlow(ConnectionStatus.CONNECTED) },
|
||||
)
|
||||
|
||||
@@ -3,6 +3,18 @@ package com.m57.hermescontrol.data.ws
|
||||
import com.m57.hermescontrol.data.model.SubagentListItem
|
||||
import com.m57.hermescontrol.data.model.SubagentListResponse
|
||||
import com.m57.hermescontrol.data.model.SubagentTailResponse
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SubagentTailParams
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -11,6 +23,48 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SubagentRepositoryTest {
|
||||
@After
|
||||
fun tearDown() {
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listSubagentsPreservesSuppressedErrorsAndDecodesResult() {
|
||||
val captured = slot<SessionIdParams>()
|
||||
mockkObject(HermesWsClient)
|
||||
coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_LIST, capture(captured), any(), true) } returns
|
||||
buildJsonObject { put("subagents", kotlinx.serialization.json.buildJsonArray { }) }
|
||||
|
||||
val response = runBlocking { SubagentRepository.listSubagents("s1") }
|
||||
|
||||
assertEquals(SessionIdParams("s1"), captured.captured)
|
||||
assertNotNull(response)
|
||||
assertTrue(response?.subagents?.isEmpty() == true)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tailSubagentSendsExactlySessionIdAndSubagentId() {
|
||||
// #1379: the contract forbids extra keys (max_bytes) and requires session_id.
|
||||
val captured = slot<SubagentTailParams>()
|
||||
mockkObject(HermesWsClient)
|
||||
coEvery { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, capture(captured), any(), true) } returns
|
||||
buildJsonObject { put("subagent_id", "sub-1") }
|
||||
|
||||
runBlocking { SubagentRepository.tailSubagent("s1", "sub-1") }
|
||||
|
||||
assertEquals(SubagentTailParams("s1", "sub-1"), captured.captured)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tailSubagentSkipsTheCallWhenAnIdIsBlank() {
|
||||
mockkObject(HermesWsClient)
|
||||
runBlocking {
|
||||
assertNull(SubagentRepository.tailSubagent("", "sub-1"))
|
||||
assertNull(SubagentRepository.tailSubagent("s1", " "))
|
||||
}
|
||||
coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.SUBAGENT_TAIL, any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWsMethodsConstants() {
|
||||
assertEquals("subagent.list", WsMethods.SUBAGENT_LIST)
|
||||
|
||||
@@ -0,0 +1,489 @@
|
||||
package com.m57.hermescontrol.data.ws
|
||||
|
||||
import android.util.Log
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.NetworkMonitor
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Response
|
||||
import okhttp3.WebSocket
|
||||
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.Assert.fail
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class WsTransportTest {
|
||||
private val testJson =
|
||||
Json {
|
||||
ignoreUnknownKeys = true
|
||||
encodeDefaults = false
|
||||
}
|
||||
|
||||
private class Fixture(
|
||||
val scope: CoroutineScope,
|
||||
json: Json,
|
||||
nowMs: () -> Long = {
|
||||
if (scope is TestScope) {
|
||||
scope.testScheduler.currentTime
|
||||
} else {
|
||||
System.nanoTime() / 1_000_000L
|
||||
}
|
||||
},
|
||||
) {
|
||||
val outboundLock = Any()
|
||||
val rawMessages = mutableListOf<String>()
|
||||
val replayEmittedEvents = mutableListOf<WsEvent>()
|
||||
val reconnectPolicy = ReconnectPolicy()
|
||||
|
||||
val rpcSentFrames = mutableListOf<String>()
|
||||
val rpcRemovedQueueIds = mutableListOf<String>()
|
||||
var rpcIdleChecks = 0
|
||||
|
||||
var transportRef: WsTransport? = null
|
||||
private val rpcIdCounter = AtomicInteger(1)
|
||||
|
||||
val connectionHealth =
|
||||
ConnectionHealth(
|
||||
scope = scope,
|
||||
isConnected = { transportRef?.isConnected ?: false },
|
||||
pingRequest = { /* ping fake unit */ },
|
||||
cancelSocket = { transportRef?.cancelSocket() },
|
||||
pendingReply = { transportRef?.pendingReply ?: false },
|
||||
nowMs = nowMs,
|
||||
)
|
||||
|
||||
val replayTracker =
|
||||
ReplayTracker(
|
||||
scope = scope,
|
||||
fetchEventsSince = { SessionEventsSinceResult() },
|
||||
emitEvent = { replayEmittedEvents.add(it) },
|
||||
)
|
||||
|
||||
val rpcChannel =
|
||||
RpcChannel(
|
||||
scope = scope,
|
||||
json = json,
|
||||
sendRequest = { method, params, onSent ->
|
||||
val id = "rpc-${rpcIdCounter.getAndIncrement()}"
|
||||
val req =
|
||||
rpcChannelRef?.createRequest(method, params)?.copy(id = id)
|
||||
?: JsonRpcRequest(id = id, method = method)
|
||||
transportRef?.send(req, onSent) ?: id
|
||||
},
|
||||
sendFrame = { frame ->
|
||||
rpcSentFrames.add(frame)
|
||||
transportRef?.sendDirectFrame(frame) ?: false
|
||||
},
|
||||
removeQueuedMessage = { id ->
|
||||
rpcRemovedQueueIds.add(id)
|
||||
transportRef?.removeQueuedMessage(id)
|
||||
},
|
||||
onIdleCheck = {
|
||||
rpcIdleChecks++
|
||||
transportRef?.disconnectIfIdleInBackground()
|
||||
},
|
||||
outboundLock = outboundLock,
|
||||
onResult = { id -> transportRef?.onRpcResult(id) },
|
||||
onError = { id -> transportRef?.onRpcError(id) },
|
||||
).also { rpcChannelRef = it }
|
||||
|
||||
val transport =
|
||||
WsTransport(
|
||||
scope = scope,
|
||||
outboundLock = outboundLock,
|
||||
reconnectPolicy = reconnectPolicy,
|
||||
connectionHealth = connectionHealth,
|
||||
replayTracker = replayTracker,
|
||||
rpcChannel = rpcChannel,
|
||||
onRawMessage = { rawMessages.add(it) },
|
||||
).also { transportRef = it }
|
||||
|
||||
companion object {
|
||||
private var rpcChannelRef: RpcChannel? = null
|
||||
}
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
mockkStatic(Log::class)
|
||||
every { Log.d(any<String>(), any<String>()) } returns 0
|
||||
every { Log.i(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>()) } returns 0
|
||||
every { Log.w(any<String>(), any<String>(), any<Throwable>()) } returns 0
|
||||
every { Log.e(any<String>(), any<String>(), any()) } returns 0
|
||||
|
||||
ActiveSessionHolder.clear()
|
||||
|
||||
mockkObject(AuthManager)
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
|
||||
mockkObject(NetworkMonitor)
|
||||
every { NetworkMonitor.isConnected } returns MutableStateFlow(true)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ActiveSessionHolder.clear()
|
||||
unmockkAll()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendAcceptCallbackRunsUnderOutboundLockAfterSocketAccept() =
|
||||
runTest {
|
||||
val fixture = Fixture(this, testJson)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns true
|
||||
|
||||
fixture.transport.connectedForTest.set(true)
|
||||
fixture.transport.webSocketForTest = socket
|
||||
|
||||
var callbackRan = false
|
||||
var callbackThreadHoldsLock = false
|
||||
val request = JsonRpcRequest(id = "req-1", method = "session.list")
|
||||
|
||||
val id =
|
||||
fixture.transport.send(
|
||||
request = request,
|
||||
onSent = {
|
||||
callbackRan = true
|
||||
callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock)
|
||||
},
|
||||
)
|
||||
|
||||
assertEquals("req-1", id)
|
||||
assertTrue(callbackRan)
|
||||
assertTrue(callbackThreadHoldsLock)
|
||||
verify(exactly = 1) { socket.send(any<String>()) }
|
||||
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rejectedRetryableSendEnqueuesAndCancelDoesNotAutoRedialWhenAutoReconnectDisabled() =
|
||||
runTest {
|
||||
val fixture = Fixture(this, testJson)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns false
|
||||
every { socket.queueSize() } returns 0L
|
||||
|
||||
fixture.transport.connectedForTest.set(true)
|
||||
fixture.transport.intentionalCloseForTest.set(false)
|
||||
fixture.transport.connectionGenerationForTest.set(1)
|
||||
fixture.transport.webSocketForTest = socket
|
||||
|
||||
var onSentCalled = false
|
||||
var callbackThreadHoldsLock = false
|
||||
val request = JsonRpcRequest(id = "req-queued", method = "prompt.submit")
|
||||
|
||||
fixture.transport.send(
|
||||
request = request,
|
||||
onSent = {
|
||||
onSentCalled = true
|
||||
callbackThreadHoldsLock = Thread.holdsLock(fixture.outboundLock)
|
||||
},
|
||||
queueIfDisconnected = true,
|
||||
)
|
||||
|
||||
// When socket rejects, request is pushed back into retryable queue
|
||||
val queue = fixture.transport.messageQueueForTest
|
||||
assertEquals(1, queue.size)
|
||||
assertTrue(queue.peek()?.contains("req-queued") == true)
|
||||
assertTrue(onSentCalled)
|
||||
assertTrue(callbackThreadHoldsLock)
|
||||
assertFalse(fixture.transport.isConnected)
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value)
|
||||
assertNull(fixture.transport.reconnectJobForTest)
|
||||
verify(exactly = 1) { socket.cancel() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleOnMessageIgnoredWhileCurrentGenerationFrameUpdatesHealthAndRawCallback() =
|
||||
runTest {
|
||||
val fixture =
|
||||
Fixture(
|
||||
scope = this,
|
||||
json = testJson,
|
||||
nowMs = { testScheduler.currentTime },
|
||||
)
|
||||
val currentSocket = mockk<WebSocket>(relaxed = true)
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
|
||||
fixture.transport.connectedForTest.set(true)
|
||||
fixture.transport.connectionGenerationForTest.set(2)
|
||||
fixture.transport.webSocketForTest = currentSocket
|
||||
|
||||
val initialTimestamp = fixture.connectionHealth.lastPongTimestamp
|
||||
assertEquals(0L, initialTimestamp)
|
||||
|
||||
val staleListener = fixture.transport.createListenerForTest(1)
|
||||
val currentListener = fixture.transport.createListenerForTest(2)
|
||||
|
||||
advanceTimeBy(1_000L)
|
||||
// Message from stale listener/generation should be discarded
|
||||
staleListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old\"}")
|
||||
staleListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"old-mismatched-gen\"}")
|
||||
assertTrue(fixture.rawMessages.isEmpty())
|
||||
assertEquals(0L, fixture.connectionHealth.lastPongTimestamp)
|
||||
|
||||
advanceTimeBy(1_000L)
|
||||
// Message from current generation but mismatched socket should be discarded
|
||||
currentListener.onMessage(staleSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"wrong-socket\"}")
|
||||
assertTrue(fixture.rawMessages.isEmpty())
|
||||
assertEquals(0L, fixture.connectionHealth.lastPongTimestamp)
|
||||
|
||||
advanceTimeBy(1_000L)
|
||||
val expectedTimestamp = testScheduler.currentTime
|
||||
// Matching generation and matching active socket passes through and updates health
|
||||
currentListener.onMessage(currentSocket, "{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}")
|
||||
assertEquals(listOf("{\"jsonrpc\":\"2.0\",\"id\":\"valid\"}"), fixture.rawMessages)
|
||||
assertEquals(expectedTimestamp, fixture.connectionHealth.lastPongTimestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleOnOpenClosesSupersededWithoutChangingOwnershipOrFlushingQueue() =
|
||||
runTest {
|
||||
val fixture = Fixture(this, testJson)
|
||||
val staleSocket = mockk<WebSocket>(relaxed = true)
|
||||
val currentSocket = mockk<WebSocket>(relaxed = true)
|
||||
val response = mockk<Response>(relaxed = true)
|
||||
|
||||
fixture.transport.intentionalCloseForTest.set(false)
|
||||
fixture.transport.connectionGenerationForTest.set(2)
|
||||
fixture.transport.webSocketForTest = currentSocket
|
||||
fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"queued-1\"}")
|
||||
|
||||
val staleListener = fixture.transport.createListenerForTest(1)
|
||||
staleListener.onOpen(staleSocket, response)
|
||||
|
||||
// Stale onOpen must close the stale socket with 1000 ("Superseded")
|
||||
verify(exactly = 1) { staleSocket.close(1000, "Superseded") }
|
||||
// Must not change active socket or flush the queue on stale socket
|
||||
verify(exactly = 0) { staleSocket.send(any<String>()) }
|
||||
assertEquals(currentSocket, fixture.transport.webSocketForTest)
|
||||
assertEquals(1, fixture.transport.messageQueueForTest.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun authClosingCodeSetsTerminalAuthExpiredAndDoesNotAutoRedial() =
|
||||
runTest {
|
||||
val fixture = Fixture(this, testJson)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns true
|
||||
|
||||
fixture.transport.connectedForTest.set(true)
|
||||
fixture.transport.intentionalCloseForTest.set(false)
|
||||
fixture.transport.connectionGenerationForTest.set(1)
|
||||
fixture.transport.webSocketForTest = socket
|
||||
|
||||
// Register a pending RPC call to verify it is NOT newly rejected
|
||||
val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap())
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
val listener = fixture.transport.createListenerForTest(1)
|
||||
listener.onClosing(socket, 4401, "unauthorized")
|
||||
|
||||
assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value)
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
assertNull(fixture.transport.reconnectJobForTest)
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
listener.onClosed(socket, 4401, "unauthorized")
|
||||
|
||||
assertFalse(fixture.transport.isConnected)
|
||||
assertEquals(ConnectionStatus.AUTH_EXPIRED, fixture.transport.connectionStatus.value)
|
||||
assertNull(fixture.transport.reconnectJobForTest)
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
pendingDeferred.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun disconnectPreservesPendingCallsWhileClearFlagControlsQueueAndLeaseCleanup() =
|
||||
runTest {
|
||||
val fixture = Fixture(this, testJson)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns true
|
||||
|
||||
fixture.transport.connectedForTest.set(true)
|
||||
fixture.transport.intentionalCloseForTest.set(false)
|
||||
fixture.transport.webSocketForTest = socket
|
||||
|
||||
// Register actual pending RPC call over socket accepting send
|
||||
val pendingDeferred = fixture.rpcChannel.request("custom.in_flight", emptyMap())
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
// Populate retryable message queue
|
||||
fixture.transport.messageQueueForTest.add("{\"jsonrpc\":\"2.0\",\"id\":\"call-1\"}")
|
||||
|
||||
// Ordinary disconnect preserves queued messages and does NOT reject pending calls
|
||||
fixture.transport.disconnect(clearPendingMessages = false)
|
||||
assertEquals(1, fixture.transport.messageQueueForTest.size)
|
||||
assertFalse(fixture.transport.isConnected)
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
// Disconnect with clearPendingMessages = true clears queue and resets leases, still not rejecting pending calls
|
||||
fixture.transport.acquireBackgroundConnectionLease()
|
||||
assertTrue(fixture.transport.hasBackgroundConnectionLease())
|
||||
|
||||
fixture.transport.disconnect(clearPendingMessages = true)
|
||||
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||
assertFalse(fixture.transport.hasBackgroundConnectionLease())
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
pendingDeferred.cancel()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun reconnectForNetworkChangeRejectsPendingCallsAndExecutesReplacementCallbackOnce() =
|
||||
runTest {
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
|
||||
val fixture = Fixture(this, testJson)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send(any<String>()) } returns true
|
||||
|
||||
fixture.transport.connectedForTest.set(true)
|
||||
fixture.transport.intentionalCloseForTest.set(false)
|
||||
fixture.transport.connectionGenerationForTest.set(1)
|
||||
fixture.transport.webSocketForTest = socket
|
||||
|
||||
// Register non-empty pending RPC while socket accepts send
|
||||
val pendingDeferred = fixture.rpcChannel.request("custom.network_call", emptyMap())
|
||||
assertFalse(pendingDeferred.isCompleted)
|
||||
|
||||
var replacementOpens = 0
|
||||
fixture.transport.reconnectForNetworkChange(
|
||||
networkAvailable = true,
|
||||
openReplacement = { replacementOpens++ },
|
||||
)
|
||||
|
||||
// Outgoing socket canceled once
|
||||
verify(exactly = 1) { socket.cancel() }
|
||||
assertEquals(1, replacementOpens)
|
||||
assertEquals(2, fixture.transport.connectionGenerationForTest.get())
|
||||
assertFalse(fixture.transport.isConnected)
|
||||
assertEquals(ConnectionStatus.RECONNECTING, fixture.transport.connectionStatus.value)
|
||||
assertNull(fixture.transport.webSocketForTest)
|
||||
|
||||
// Network replacement uses the default connection-lost exception, not a timeout.
|
||||
assertTrue(pendingDeferred.isCompleted)
|
||||
try {
|
||||
pendingDeferred.await()
|
||||
fail("Expected HermesRpcException")
|
||||
} catch (exception: HermesWsClient.HermesRpcException) {
|
||||
assertEquals(0, exception.code)
|
||||
assertEquals("Connection lost — request cancelled", exception.message)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun combinedLeasesSurviveIdleBackgroundUntilBothReleased() =
|
||||
runTest {
|
||||
val fixture = Fixture(this, testJson)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
|
||||
fixture.transport.connectedForTest.set(true)
|
||||
fixture.transport.webSocketForTest = socket
|
||||
fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED)
|
||||
|
||||
fixture.transport.acquireExternalActivityConnectionLease()
|
||||
fixture.transport.acquireBackgroundConnectionLease()
|
||||
assertTrue(fixture.transport.hasBackgroundConnectionLease())
|
||||
|
||||
// Backgrounding app with both leases active keeps connection alive
|
||||
fixture.transport.setAppForeground(false)
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
|
||||
// Releasing external lease keeps connection alive due to background lease
|
||||
fixture.transport.releaseExternalActivityConnectionLease()
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
|
||||
// Releasing background lease releases all leases and disconnects idle background socket
|
||||
fixture.transport.releaseBackgroundConnectionLease()
|
||||
assertFalse(fixture.transport.isConnected)
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun observeEventMarksPendingReplyStartAndCompleteRespectingIdlePolicy() =
|
||||
runTest {
|
||||
val fixture = Fixture(this, testJson)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
|
||||
fixture.transport.connectedForTest.set(true)
|
||||
fixture.transport.webSocketForTest = socket
|
||||
fixture.transport.setConnectionStatusForTest(ConnectionStatus.CONNECTED)
|
||||
|
||||
// When a message start event arrives BEFORE setAppForeground(false)
|
||||
val startEvent = WsEvent.MessageStart(sessionId = "s1")
|
||||
fixture.transport.observeEvent(startEvent)
|
||||
assertTrue(fixture.transport.pendingReply)
|
||||
|
||||
// Setting app foreground to false while pending reply is true stays connected
|
||||
fixture.transport.setAppForeground(false)
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
|
||||
// Idle disconnect check while pending reply is true keeps socket connected
|
||||
fixture.transport.disconnectIfIdleInBackground()
|
||||
assertTrue(fixture.transport.isConnected)
|
||||
|
||||
// Message complete event clears pending reply and immediately idle disconnects
|
||||
val completeEvent = WsEvent.MessageComplete(text = "done", sessionId = "s1")
|
||||
fixture.transport.observeEvent(completeEvent)
|
||||
assertFalse(fixture.transport.pendingReply)
|
||||
assertFalse(fixture.transport.isConnected)
|
||||
assertEquals(ConnectionStatus.DISCONNECTED, fixture.transport.connectionStatus.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sendDirectFrameBypassesQueueAndReturnsFalseWhenDisconnected() =
|
||||
runTest {
|
||||
val fixture = Fixture(this, testJson)
|
||||
val socket = mockk<WebSocket>(relaxed = true)
|
||||
every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}") } returns true
|
||||
every { socket.send("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}") } returns false
|
||||
|
||||
fixture.transport.connectedForTest.set(true)
|
||||
fixture.transport.webSocketForTest = socket
|
||||
|
||||
// 1. Connected with socket accepting send -> returns true, no queue
|
||||
val connectedResult =
|
||||
fixture.transport.sendDirectFrame(
|
||||
"{\"jsonrpc\":\"2.0\",\"method\":\"connected_frame\"}",
|
||||
)
|
||||
assertTrue(connectedResult)
|
||||
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||
|
||||
// 2. Connected with socket rejecting send -> returns false, no queue
|
||||
val sendFailsResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"send_fails\"}")
|
||||
assertFalse(sendFailsResult)
|
||||
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||
|
||||
// 3. Disconnected -> returns false, no queue
|
||||
fixture.transport.connectedForTest.set(false)
|
||||
fixture.transport.webSocketForTest = null
|
||||
|
||||
val disconnectedResult = fixture.transport.sendDirectFrame("{\"jsonrpc\":\"2.0\",\"method\":\"direct\"}")
|
||||
assertFalse(disconnectedResult)
|
||||
assertTrue(fixture.transport.messageQueueForTest.isEmpty())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ApprovalsTypesTest {
|
||||
@Test
|
||||
fun approvalPendingParamsEncodesSessionIdExactly() {
|
||||
val params = ApprovalPendingParams(sessionId = "sess-appr-pending-1")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalPendingParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-appr-pending-1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun approvalReceivedParamsEncodesSessionIdAndRequestId() {
|
||||
val params =
|
||||
ApprovalReceivedParams(
|
||||
sessionId = "sess-appr-rec-1",
|
||||
requestId = "req-appr-rec-99",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalReceivedParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-appr-rec-1")
|
||||
put("request_id", "req-appr-rec-99")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "request_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun approvalRespondParamsEncodesOnlySessionIdWhenOptionalsNull() {
|
||||
val params = ApprovalRespondParams(sessionId = "sess-respond-1")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-respond-1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun approvalRespondParamsEncodesAllFieldsWhenPresent() {
|
||||
val params =
|
||||
ApprovalRespondParams(
|
||||
sessionId = "sess-respond-2",
|
||||
choice = "allow",
|
||||
all = true,
|
||||
requestId = "req-respond-2",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-respond-2")
|
||||
put("choice", "allow")
|
||||
put("all", true)
|
||||
put("request_id", "req-respond-2")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "choice", "all", "request_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun approvalRespondParamsEncodesExplicitFalseForAll() {
|
||||
val params =
|
||||
ApprovalRespondParams(
|
||||
sessionId = "sess-respond-3",
|
||||
choice = "deny",
|
||||
all = false,
|
||||
requestId = "req-respond-3",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-respond-3")
|
||||
put("choice", "deny")
|
||||
put("all", false)
|
||||
put("request_id", "req-respond-3")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "choice", "all", "request_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun approvalRespondParamsOmitsOnlyNullFields() {
|
||||
val params =
|
||||
ApprovalRespondParams(
|
||||
sessionId = "sess-respond-4",
|
||||
choice = "allow",
|
||||
all = null,
|
||||
requestId = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ApprovalRespondParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-respond-4")
|
||||
put("choice", "allow")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "choice"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionActiveListParamsEncodesToEmptyJsonObjectWhenCurrentSessionIdNull() {
|
||||
val params = SessionActiveListParams()
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionActiveListParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionActiveListParamsEncodesCurrentSessionIdWhenSet() {
|
||||
val params = SessionActiveListParams(currentSessionId = "sess-active-curr-1")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionActiveListParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("current_session_id", "sess-active-curr-1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("current_session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcMethodsRegistrationMatchesContract() {
|
||||
assertEquals("approval.pending", RpcMethods.APPROVAL_PENDING.name)
|
||||
assertEquals("approval.received", RpcMethods.APPROVAL_RECEIVED.name)
|
||||
assertEquals("approval.respond", RpcMethods.APPROVAL_RESPOND.name)
|
||||
assertEquals("session.active_list", RpcMethods.SESSION_ACTIVE_LIST.name)
|
||||
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_PENDING))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_RECEIVED))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.APPROVAL_RESPOND))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_ACTIVE_LIST))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsonElementPassthroughMethodsDecodeResultUntouched() {
|
||||
val arbitraryPayload =
|
||||
buildJsonObject {
|
||||
put("status", "pending")
|
||||
put("count", 3)
|
||||
put("nested", buildJsonObject { put("detail", "value") })
|
||||
}
|
||||
|
||||
val decodedPending: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.APPROVAL_PENDING.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
val decodedReceived: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.APPROVAL_RECEIVED.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
val decodedRespond: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.APPROVAL_RESPOND.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
val decodedActiveList: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.SESSION_ACTIVE_LIST.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
|
||||
assertEquals(arbitraryPayload, decodedPending)
|
||||
assertEquals(arbitraryPayload, decodedReceived)
|
||||
assertEquals(arbitraryPayload, decodedRespond)
|
||||
assertEquals(arbitraryPayload, decodedActiveList)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BillingVaultTypesTest {
|
||||
@Test
|
||||
fun emptyParamsEncodesToEmptyJsonObject() {
|
||||
val params = EmptyParams
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(EmptyParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionPreviewParamsEncodesSubscriptionTypeIdWhenPresent() {
|
||||
val params = SubscriptionPreviewParams(subscriptionTypeId = "pro-monthly")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("subscription_type_id", "pro-monthly")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionPreviewParamsOmitsNullSubscriptionTypeId() {
|
||||
val params = SubscriptionPreviewParams(subscriptionTypeId = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionPreviewParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionChangeParamsEncodesAllFieldsWhenPresent() {
|
||||
val params =
|
||||
SubscriptionChangeParams(
|
||||
subscriptionTypeId = "tier-2",
|
||||
cancel = true,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("subscription_type_id", "tier-2")
|
||||
put("cancel", true)
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionChangeParamsEncodesExplicitFalseForCancel() {
|
||||
val params =
|
||||
SubscriptionChangeParams(
|
||||
subscriptionTypeId = "tier-1",
|
||||
cancel = false,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("subscription_type_id", "tier-1")
|
||||
put("cancel", false)
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("subscription_type_id", "cancel"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionChangeParamsOmitsOnlyNullFields() {
|
||||
val paramsOnlyCancel = SubscriptionChangeParams(subscriptionTypeId = null, cancel = false)
|
||||
val encodedOnlyCancel =
|
||||
OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyCancel)
|
||||
val expectedOnlyCancel =
|
||||
buildJsonObject {
|
||||
put("cancel", false)
|
||||
}
|
||||
assertEquals(expectedOnlyCancel, encodedOnlyCancel)
|
||||
assertTrue(encodedOnlyCancel is JsonObject)
|
||||
assertEquals(setOf("cancel"), (encodedOnlyCancel as JsonObject).keys)
|
||||
|
||||
val paramsOnlyType = SubscriptionChangeParams(subscriptionTypeId = "tier-3", cancel = null)
|
||||
val encodedOnlyType =
|
||||
OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsOnlyType)
|
||||
val expectedOnlyType =
|
||||
buildJsonObject {
|
||||
put("subscription_type_id", "tier-3")
|
||||
}
|
||||
assertEquals(expectedOnlyType, encodedOnlyType)
|
||||
assertTrue(encodedOnlyType is JsonObject)
|
||||
assertEquals(setOf("subscription_type_id"), (encodedOnlyType as JsonObject).keys)
|
||||
|
||||
val paramsEmpty = SubscriptionChangeParams(subscriptionTypeId = null, cancel = null)
|
||||
val encodedEmpty =
|
||||
OkHttpProvider.json.encodeToJsonElement(SubscriptionChangeParams.serializer(), paramsEmpty)
|
||||
val expectedEmpty = buildJsonObject {}
|
||||
assertEquals(expectedEmpty, encodedEmpty)
|
||||
assertTrue(encodedEmpty is JsonObject)
|
||||
assertTrue((encodedEmpty as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionUpgradeParamsEncodesSubscriptionTypeIdWhenPresent() {
|
||||
val params = SubscriptionUpgradeParams(subscriptionTypeId = "enterprise-yearly")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("subscription_type_id", "enterprise-yearly")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("subscription_type_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun subscriptionUpgradeParamsOmitsNullSubscriptionTypeId() {
|
||||
val params = SubscriptionUpgradeParams(subscriptionTypeId = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SubscriptionUpgradeParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vaultSourceSetParamsEncodesAllFieldsWhenPresent() {
|
||||
val params =
|
||||
VaultSourceSetParams(
|
||||
name = "1password",
|
||||
enabled = true,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("name", "1password")
|
||||
put("enabled", true)
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vaultSourceSetParamsEncodesExplicitFalseForEnabled() {
|
||||
val params =
|
||||
VaultSourceSetParams(
|
||||
name = "bitwarden",
|
||||
enabled = false,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("name", "bitwarden")
|
||||
put("enabled", false)
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("name", "enabled"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vaultSourceSetParamsOmitsNullFields() {
|
||||
val params = VaultSourceSetParams(name = "keepass", enabled = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("name", "keepass")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("name"), (encoded as JsonObject).keys)
|
||||
|
||||
val paramsEmpty = VaultSourceSetParams(name = null, enabled = null)
|
||||
val encodedEmpty =
|
||||
OkHttpProvider.json.encodeToJsonElement(VaultSourceSetParams.serializer(), paramsEmpty)
|
||||
val expectedEmpty = buildJsonObject {}
|
||||
assertEquals(expectedEmpty, encodedEmpty)
|
||||
assertTrue(encodedEmpty is JsonObject)
|
||||
assertTrue((encodedEmpty as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vaultUnlockParamsEncodesAllFieldsWhenPresent() {
|
||||
val params =
|
||||
VaultUnlockParams(
|
||||
name = "local-vault",
|
||||
password = "secret-password",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("name", "local-vault")
|
||||
put("password", "secret-password")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("name", "password"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vaultUnlockParamsOmitsNullFields() {
|
||||
val params = VaultUnlockParams(name = "local-vault", password = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("name", "local-vault")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("name"), (encoded as JsonObject).keys)
|
||||
|
||||
val paramsEmpty = VaultUnlockParams(name = null, password = null)
|
||||
val encodedEmpty =
|
||||
OkHttpProvider.json.encodeToJsonElement(VaultUnlockParams.serializer(), paramsEmpty)
|
||||
val expectedEmpty = buildJsonObject {}
|
||||
assertEquals(expectedEmpty, encodedEmpty)
|
||||
assertTrue(encodedEmpty is JsonObject)
|
||||
assertTrue((encodedEmpty as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vaultLockParamsEncodesEmptyJsonObjectWhenNameNull() {
|
||||
val params = VaultLockParams(name = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun vaultLockParamsEncodesNameWhenSet() {
|
||||
val params = VaultLockParams(name = "primary-vault")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(VaultLockParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("name", "primary-vault")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("name"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcMethodsRegistrationMatchesContract() {
|
||||
assertEquals("subscription.state", RpcMethods.SUBSCRIPTION_STATE.name)
|
||||
assertEquals("usage.bars", RpcMethods.USAGE_BARS.name)
|
||||
assertEquals("subscription.resume", RpcMethods.SUBSCRIPTION_RESUME.name)
|
||||
assertEquals("vault.list", RpcMethods.VAULT_LIST.name)
|
||||
assertEquals("vault.sources", RpcMethods.VAULT_SOURCES.name)
|
||||
assertEquals("subscription.preview", RpcMethods.SUBSCRIPTION_PREVIEW.name)
|
||||
assertEquals("subscription.change", RpcMethods.SUBSCRIPTION_CHANGE.name)
|
||||
assertEquals("subscription.upgrade", RpcMethods.SUBSCRIPTION_UPGRADE.name)
|
||||
assertEquals("vault.source.set", RpcMethods.VAULT_SOURCE_SET.name)
|
||||
assertEquals("vault.unlock", RpcMethods.VAULT_UNLOCK.name)
|
||||
assertEquals("vault.lock", RpcMethods.VAULT_LOCK.name)
|
||||
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_STATE))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.USAGE_BARS))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_RESUME))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LIST))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCES))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_PREVIEW))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_CHANGE))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SUBSCRIPTION_UPGRADE))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_SOURCE_SET))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_UNLOCK))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.VAULT_LOCK))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsonElementPassthroughMethodsDecodeResultUntouched() {
|
||||
val arbitraryPayload =
|
||||
buildJsonObject {
|
||||
put("tier", "pro")
|
||||
put("credits", 4200)
|
||||
put("details", buildJsonObject { put("active", true) })
|
||||
}
|
||||
|
||||
val methods =
|
||||
listOf(
|
||||
RpcMethods.SUBSCRIPTION_STATE,
|
||||
RpcMethods.USAGE_BARS,
|
||||
RpcMethods.SUBSCRIPTION_RESUME,
|
||||
RpcMethods.VAULT_LIST,
|
||||
RpcMethods.VAULT_SOURCES,
|
||||
RpcMethods.SUBSCRIPTION_PREVIEW,
|
||||
RpcMethods.SUBSCRIPTION_CHANGE,
|
||||
RpcMethods.SUBSCRIPTION_UPGRADE,
|
||||
RpcMethods.VAULT_SOURCE_SET,
|
||||
RpcMethods.VAULT_UNLOCK,
|
||||
RpcMethods.VAULT_LOCK,
|
||||
)
|
||||
|
||||
for (method in methods) {
|
||||
val decoded: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
method.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
assertEquals("Method ${method.name} must pass raw result untouched", arbitraryPayload, decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatCommandsTypesTest {
|
||||
private val json = OkHttpProvider.json
|
||||
|
||||
@Test
|
||||
fun commandsCatalogEncodesAsEmptyObject() {
|
||||
assertEquals(
|
||||
buildJsonObject { },
|
||||
json.encodeToJsonElement(CommandsCatalogParams.serializer(), CommandsCatalogParams),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commandDispatchKeepsEmptyArgAndOmitsNullOnes() {
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("name", "help")
|
||||
put("arg", "")
|
||||
put("session_id", "s1")
|
||||
},
|
||||
json.encodeToJsonElement(
|
||||
CommandDispatchParams.serializer(),
|
||||
CommandDispatchParams(name = "help", arg = "", sessionId = "s1"),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
buildJsonObject { put("name", "help") },
|
||||
json.encodeToJsonElement(CommandDispatchParams.serializer(), CommandDispatchParams(name = "help")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun slashExecEncodesSessionAndCommand() {
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("command", "/status")
|
||||
},
|
||||
json.encodeToJsonElement(
|
||||
SlashExecParams.serializer(),
|
||||
SlashExecParams(sessionId = "s1", command = "/status"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fileAttachSendsDataUrlAndNameWithoutPath() {
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("data_url", "data:text/plain;base64,QQ==")
|
||||
put("name", "a.txt")
|
||||
},
|
||||
json.encodeToJsonElement(
|
||||
FileAttachParams.serializer(),
|
||||
FileAttachParams(sessionId = "s1", dataUrl = "data:text/plain;base64,QQ==", name = "a.txt"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageAttachBytesEncodesContentBase64FilenameAndExt() {
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("content_base64", "data:image/png;base64,QQ==")
|
||||
put("filename", "p.png")
|
||||
put("ext", "png")
|
||||
},
|
||||
json.encodeToJsonElement(
|
||||
ImageAttachBytesParams.serializer(),
|
||||
ImageAttachBytesParams(
|
||||
sessionId = "s1",
|
||||
contentBase64 = "data:image/png;base64,QQ==",
|
||||
filename = "p.png",
|
||||
ext = "png",
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptorsAreRegisteredWithContractNames() {
|
||||
listOf(
|
||||
RpcMethods.COMMANDS_CATALOG to "commands.catalog",
|
||||
RpcMethods.COMMAND_DISPATCH to "command.dispatch",
|
||||
RpcMethods.SLASH_EXEC to "slash.exec",
|
||||
RpcMethods.FILE_ATTACH to "file.attach",
|
||||
RpcMethods.IMAGE_ATTACH_BYTES to "image.attach_bytes",
|
||||
).forEach { (method, name) ->
|
||||
assertTrue("$name not in RpcMethods.all", method in RpcMethods.all)
|
||||
assertEquals(name, method.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConfigModelTypesTest {
|
||||
private val json = OkHttpProvider.json
|
||||
|
||||
@Test
|
||||
fun configSetEncodesOnlyKeyAndValueByDefault() {
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("key", "fast")
|
||||
put("value", "normal")
|
||||
},
|
||||
json.encodeToJsonElement(ConfigSetParams.serializer(), ConfigSetParams(key = "fast", value = "normal")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configSetEncodesSessionScopeAndConfirmWhenPresent() {
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("key", "model")
|
||||
put("value", "gpt-4o --provider openai --session")
|
||||
put("session_id", "s1")
|
||||
put("scope", "global")
|
||||
put("confirm_expensive_model", true)
|
||||
},
|
||||
json.encodeToJsonElement(
|
||||
ConfigSetParams.serializer(),
|
||||
ConfigSetParams(
|
||||
key = "model",
|
||||
value = "gpt-4o --provider openai --session",
|
||||
sessionId = "s1",
|
||||
scope = "global",
|
||||
confirmExpensiveModel = true,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configGetOmitsNullSessionAndCwd() {
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("key", "reasoning")
|
||||
put("session_id", "s1")
|
||||
},
|
||||
json.encodeToJsonElement(
|
||||
ConfigGetParams.serializer(),
|
||||
ConfigGetParams(key = "reasoning", sessionId = "s1"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun modelOptionsKeepsExplicitFalseAndOmitsUnsetFlags() {
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("refresh", true)
|
||||
put("include_unconfigured", false)
|
||||
},
|
||||
json.encodeToJsonElement(
|
||||
ModelOptionsParams.serializer(),
|
||||
ModelOptionsParams(refresh = true, includeUnconfigured = false),
|
||||
),
|
||||
)
|
||||
assertEquals(
|
||||
buildJsonObject { },
|
||||
json.encodeToJsonElement(ModelOptionsParams.serializer(), ModelOptionsParams()),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clientCapabilitiesAlwaysEncodesServerRequests() {
|
||||
assertEquals(
|
||||
buildJsonObject { put("server_requests", true) },
|
||||
json.encodeToJsonElement(
|
||||
ClientCapabilitiesParams.serializer(),
|
||||
ClientCapabilitiesParams(serverRequests = true),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptorsAreRegisteredWithContractNames() {
|
||||
listOf(
|
||||
RpcMethods.CONFIG_SET to "config.set",
|
||||
RpcMethods.CONFIG_GET to "config.get",
|
||||
RpcMethods.MODEL_OPTIONS to "model.options",
|
||||
RpcMethods.CLIENT_CAPABILITIES to "client.capabilities",
|
||||
).forEach { (method, name) ->
|
||||
assertTrue("$name not in RpcMethods.all", method in RpcMethods.all)
|
||||
assertEquals(name, method.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
+257
@@ -0,0 +1,257 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectionOperationTypesTest {
|
||||
@Test
|
||||
fun connectionRespondParamsWithApprovedEnvAndAccountOwnerEncodesExactWireJson() {
|
||||
val params =
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
opId = "op-approved-1",
|
||||
result =
|
||||
ConnectionAnswer(
|
||||
targets =
|
||||
listOf(
|
||||
ConnectionAnswerTarget(
|
||||
name = "linear",
|
||||
status = "approved",
|
||||
detail = "User accepted linear integration",
|
||||
env = mapOf("API_KEY" to "dummy_secret_key_123"),
|
||||
),
|
||||
),
|
||||
settledBy = null,
|
||||
),
|
||||
profile = "work",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-approved-1")
|
||||
put(
|
||||
"result",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"targets",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("name", "linear")
|
||||
put("status", "approved")
|
||||
put("detail", "User accepted linear integration")
|
||||
put(
|
||||
"env",
|
||||
buildJsonObject {
|
||||
put("API_KEY", "dummy_secret_key_123")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
put("profile", "work")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "op_id", "result", "profile"), (encoded as JsonObject).keys)
|
||||
val resultObj = (encoded as JsonObject)["result"] as JsonObject
|
||||
assertEquals(setOf("targets"), resultObj.keys)
|
||||
assertFalse(resultObj.containsKey("settled_by"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionRespondParamsWithSkippedOmittedEnvAndSessionOwnerEncodesExactWireJson() {
|
||||
val params =
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.session("sess-xyz"),
|
||||
opId = "op-skip-2",
|
||||
result =
|
||||
ConnectionAnswer(
|
||||
targets =
|
||||
listOf(
|
||||
ConnectionAnswerTarget(
|
||||
name = "github",
|
||||
status = "skipped",
|
||||
detail = null,
|
||||
env = null,
|
||||
),
|
||||
),
|
||||
settledBy = null,
|
||||
),
|
||||
profile = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "sess-xyz")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-skip-2")
|
||||
put(
|
||||
"result",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"targets",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("name", "github")
|
||||
put("status", "skipped")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys)
|
||||
assertFalse((encoded as JsonObject).containsKey("profile"))
|
||||
|
||||
val resultObj = (encoded as JsonObject)["result"] as JsonObject
|
||||
assertEquals(setOf("targets"), resultObj.keys)
|
||||
assertFalse(resultObj.containsKey("settled_by"))
|
||||
|
||||
val targetObj = (resultObj["targets"] as kotlinx.serialization.json.JsonArray)[0] as JsonObject
|
||||
assertEquals(setOf("name", "status"), targetObj.keys)
|
||||
assertFalse(targetObj.containsKey("env"))
|
||||
assertFalse(targetObj.containsKey("detail"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionRespondParamsWithContinueAndOmittedTargetsEncodesExactWireJson() {
|
||||
val params =
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.session("sess-cont-3"),
|
||||
opId = "op-cont-3",
|
||||
result =
|
||||
ConnectionAnswer(
|
||||
targets = null,
|
||||
settledBy = "continue",
|
||||
),
|
||||
profile = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "sess-cont-3")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-cont-3")
|
||||
put(
|
||||
"result",
|
||||
buildJsonObject {
|
||||
put("settled_by", "continue")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "op_id", "result"), (encoded as JsonObject).keys)
|
||||
assertFalse((encoded as JsonObject).containsKey("profile"))
|
||||
|
||||
val resultObj = (encoded as JsonObject)["result"] as JsonObject
|
||||
assertEquals(setOf("settled_by"), resultObj.keys)
|
||||
assertFalse(resultObj.containsKey("targets"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndEncodesAllFieldsWhenProfilePresent() {
|
||||
val params =
|
||||
ConnectorsOperationStatusParams(
|
||||
owner = ConnectorOwner.session("sess-wake-1"),
|
||||
opId = "op-wake-1",
|
||||
profile = "staging",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "sess-wake-1")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-wake-1")
|
||||
put("profile", "staging")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsOperationWakeUsesConnectorsOperationStatusParamsAndOmitsProfileWhenNull() {
|
||||
val params =
|
||||
ConnectorsOperationStatusParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
opId = "op-wake-2",
|
||||
profile = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-wake-2")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys)
|
||||
assertFalse((encoded as JsonObject).containsKey("profile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionRespondAndWakeAreRegisteredInGatewayContractRpcMethodsAll() {
|
||||
val allMethods = RpcMethods.all
|
||||
val respondMethod = allMethods.firstOrNull { it.name == "connection.respond" }
|
||||
val wakeMethod = allMethods.firstOrNull { it.name == "connectors.operation.wake" }
|
||||
|
||||
org.junit.Assert.assertNotNull("connection.respond must be registered in RpcMethods.all", respondMethod)
|
||||
org.junit.Assert.assertNotNull("connectors.operation.wake must be registered in RpcMethods.all", wakeMethod)
|
||||
|
||||
assertEquals(RpcMethods.CONNECTION_RESPOND, respondMethod)
|
||||
assertEquals(RpcMethods.CONNECTORS_OPERATION_WAKE, wakeMethod)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,539 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ConnectorsTypesTest {
|
||||
@Test
|
||||
fun connectorOwnerAccountEncodesToTypeAccountWithoutSessionId() {
|
||||
val owner = ConnectorOwner.account()
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("type"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorOwnerSessionEncodesToTypeSessionAndSessionId() {
|
||||
val owner = ConnectorOwner.session("s1")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorOwner.serializer(), owner)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "s1")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("type", "session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsListParamsEncodesOwnerAndProfileWhenProfileProvided() {
|
||||
val params = ConnectorsListParams(owner = ConnectorOwner.account(), profile = "work")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
},
|
||||
)
|
||||
put("profile", "work")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsListParamsOmitsProfileWhenNull() {
|
||||
val params = ConnectorsListParams(owner = ConnectorOwner.session("s1"), profile = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsListParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "s1")
|
||||
},
|
||||
)
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsConnectParamsEncodesAllFieldsWhenPresent() {
|
||||
val params =
|
||||
ConnectorsConnectParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
connectors = listOf("slack", "github"),
|
||||
reconnect = true,
|
||||
profile = "personal",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
},
|
||||
)
|
||||
put(
|
||||
"connectors",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("slack"))
|
||||
add(JsonPrimitive("github"))
|
||||
},
|
||||
)
|
||||
put("reconnect", true)
|
||||
put("profile", "personal")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "connectors", "reconnect", "profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsConnectParamsEncodesExplicitFalseForReconnect() {
|
||||
val params =
|
||||
ConnectorsConnectParams(
|
||||
owner = ConnectorOwner.session("s2"),
|
||||
connectors = listOf("jira"),
|
||||
reconnect = false,
|
||||
profile = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "s2")
|
||||
},
|
||||
)
|
||||
put(
|
||||
"connectors",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("jira"))
|
||||
},
|
||||
)
|
||||
put("reconnect", false)
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "connectors", "reconnect"), (encoded as JsonObject).keys)
|
||||
assertFalse((encoded as JsonObject).containsKey("profile"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsConnectParamsOmitsNullReconnectAndProfile() {
|
||||
val params =
|
||||
ConnectorsConnectParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
connectors = emptyList(),
|
||||
reconnect = null,
|
||||
profile = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsConnectParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
},
|
||||
)
|
||||
put("connectors", buildJsonArray {})
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "connectors"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsOperationStatusParamsEncodesAllFieldsWhenPresent() {
|
||||
val params =
|
||||
ConnectorsOperationStatusParams(
|
||||
owner = ConnectorOwner.session("sess-1"),
|
||||
opId = "op-99",
|
||||
profile = "work",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "sess-1")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-99")
|
||||
put("profile", "work")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "op_id", "profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsOperationStatusParamsOmitsNullProfile() {
|
||||
val params =
|
||||
ConnectorsOperationStatusParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
opId = "op-100",
|
||||
profile = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "account")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-100")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("owner", "op_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsCatalogParamsEncodesProfileWhenPresent() {
|
||||
val params = ConnectorsCatalogParams(profile = "default")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("profile", "default")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsCatalogParamsOmitsNullProfileResultingInEmptyJsonObject() {
|
||||
val params = ConnectorsCatalogParams(profile = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsCatalogParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsAccountsParamsEncodesAllFieldsWhenPresent() {
|
||||
val params = ConnectorsAccountsParams(connector = "google", profile = "work")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("connector", "google")
|
||||
put("profile", "work")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("connector", "profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsAccountsParamsOmitsNullFieldsResultingInEmptyJsonObject() {
|
||||
val params = ConnectorsAccountsParams(connector = null, profile = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsAccountsParamsEncodesConnectorOnlyWhenProfileNull() {
|
||||
val params = ConnectorsAccountsParams(connector = "github", profile = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("connector", "github")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("connector"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsAccountsRemoveParamsEncodesConnectionIdAndProfile() {
|
||||
val params = ConnectorsAccountsRemoveParams(connectionId = "conn-123", profile = "work")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("connection_id", "conn-123")
|
||||
put("profile", "work")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("connection_id", "profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsAccountsRemoveParamsOmitsNullProfile() {
|
||||
val params = ConnectorsAccountsRemoveParams(connectionId = "conn-456", profile = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsAccountsRemoveParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("connection_id", "conn-456")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("connection_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsPolicyGetParamsEncodesProfileWhenPresent() {
|
||||
val params = ConnectorsPolicyGetParams(profile = "custom-profile")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("profile", "custom-profile")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsPolicyGetParamsOmitsNullProfileResultingInEmptyJsonObject() {
|
||||
val params = ConnectorsPolicyGetParams(profile = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicyGetParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsPolicySetParamsEncodesNestedChangeWithArraysUntouched() {
|
||||
val nestedChange =
|
||||
buildJsonObject {
|
||||
put("action", "allow")
|
||||
put(
|
||||
"tools",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("gmail.send"))
|
||||
add(JsonPrimitive("gmail.list"))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"scope",
|
||||
buildJsonObject {
|
||||
put("level", "read-write")
|
||||
put(
|
||||
"allowed_users",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("alice"))
|
||||
add(JsonPrimitive("bob"))
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
val params =
|
||||
ConnectorsPolicySetParams(
|
||||
change = nestedChange,
|
||||
expectedRevision = "4",
|
||||
profile = "work",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("change", nestedChange)
|
||||
put("expected_revision", "4")
|
||||
put("profile", "work")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("change", "expected_revision", "profile"), (encoded as JsonObject).keys)
|
||||
assertEquals(nestedChange, (encoded as JsonObject)["change"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsPolicySetParamsOmitsNullProfile() {
|
||||
val change = buildJsonObject { put("enabled", true) }
|
||||
val params =
|
||||
ConnectorsPolicySetParams(
|
||||
change = change,
|
||||
expectedRevision = "1",
|
||||
profile = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsPolicySetParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("change", change)
|
||||
put("expected_revision", "1")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("change", "expected_revision"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsToolsParamsEncodesAllFieldsWhenPresent() {
|
||||
val params =
|
||||
ConnectorsToolsParams(
|
||||
slug = "gmail",
|
||||
refresh = true,
|
||||
profile = "work",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("slug", "gmail")
|
||||
put("refresh", true)
|
||||
put("profile", "work")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("slug", "refresh", "profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsToolsParamsEncodesExplicitFalseForRefresh() {
|
||||
val params =
|
||||
ConnectorsToolsParams(
|
||||
slug = "slack",
|
||||
refresh = false,
|
||||
profile = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("slug", "slack")
|
||||
put("refresh", false)
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("slug", "refresh"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectorsToolsParamsOmitsNullRefreshAndProfile() {
|
||||
val params =
|
||||
ConnectorsToolsParams(
|
||||
slug = "github",
|
||||
refresh = null,
|
||||
profile = null,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectorsToolsParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("slug", "github")
|
||||
}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("slug"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcMethodsRegistrationMatchesContract() {
|
||||
assertEquals("connectors.list", RpcMethods.CONNECTORS_LIST.name)
|
||||
assertEquals("connectors.connect", RpcMethods.CONNECTORS_CONNECT.name)
|
||||
assertEquals("connectors.operation.status", RpcMethods.CONNECTORS_OPERATION_STATUS.name)
|
||||
assertEquals("connectors.catalog", RpcMethods.CONNECTORS_CATALOG.name)
|
||||
assertEquals("connectors.accounts", RpcMethods.CONNECTORS_ACCOUNTS.name)
|
||||
assertEquals("connectors.accounts.remove", RpcMethods.CONNECTORS_ACCOUNTS_REMOVE.name)
|
||||
assertEquals("connectors.policy.get", RpcMethods.CONNECTORS_POLICY_GET.name)
|
||||
assertEquals("connectors.policy.set", RpcMethods.CONNECTORS_POLICY_SET.name)
|
||||
assertEquals("connectors.tools", RpcMethods.CONNECTORS_TOOLS.name)
|
||||
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_LIST))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CONNECT))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_OPERATION_STATUS))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_CATALOG))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_ACCOUNTS_REMOVE))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_GET))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_POLICY_SET))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.CONNECTORS_TOOLS))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsonElementPassthroughMethodsDecodeResultUntouched() {
|
||||
val arbitraryPayload =
|
||||
buildJsonObject {
|
||||
put("status", "ok")
|
||||
put(
|
||||
"items",
|
||||
buildJsonArray {
|
||||
add(JsonPrimitive("elem1"))
|
||||
add(JsonPrimitive("elem2"))
|
||||
},
|
||||
)
|
||||
put("nested", buildJsonObject { put("revision", 10) })
|
||||
}
|
||||
|
||||
val methods =
|
||||
listOf(
|
||||
RpcMethods.CONNECTORS_LIST,
|
||||
RpcMethods.CONNECTORS_CONNECT,
|
||||
RpcMethods.CONNECTORS_OPERATION_STATUS,
|
||||
RpcMethods.CONNECTORS_CATALOG,
|
||||
RpcMethods.CONNECTORS_ACCOUNTS,
|
||||
RpcMethods.CONNECTORS_ACCOUNTS_REMOVE,
|
||||
RpcMethods.CONNECTORS_POLICY_GET,
|
||||
RpcMethods.CONNECTORS_POLICY_SET,
|
||||
RpcMethods.CONNECTORS_TOOLS,
|
||||
)
|
||||
|
||||
for (method in methods) {
|
||||
val decoded: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
method.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
assertEquals("Method " + method.name + " must pass raw result untouched", arbitraryPayload, decoded)
|
||||
}
|
||||
}
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.ws.GatewayContract
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ParamsDescriptorCheckTest {
|
||||
@Serializable
|
||||
private data class ValidParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("last_seen") val lastSeen: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class ExtraFieldParams(
|
||||
@SerialName("session_id") val sessionId: String,
|
||||
@SerialName("since_seq") val sinceSeq: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class MissingRequiredParams(
|
||||
@SerialName("last_seen") val lastSeen: Int? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DefaultOnRequiredParams(
|
||||
@SerialName("session_id") val sessionId: String = "default_id",
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class NullableRequiredParams(
|
||||
@SerialName("session_id") val sessionId: String?,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
private data class DeliverMissingProfileParams(
|
||||
val message: String,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun correctClassPasses() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
ValidParams.serializer().descriptor,
|
||||
)
|
||||
assertTrue(problems.joinToString("\n"), problems.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extraFieldIsRejected() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
ExtraFieldParams.serializer().descriptor,
|
||||
)
|
||||
assertEquals(1, problems.count { "unknown param 'since_seq'" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun missingRequiredFieldIsRejected() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
MissingRequiredParams.serializer().descriptor,
|
||||
)
|
||||
assertEquals(1, problems.count { "missing required param 'session_id'" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requiredFieldWithDefaultValueIsRejected() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
DefaultOnRequiredParams.serializer().descriptor,
|
||||
)
|
||||
assertEquals(1, problems.count { "required param 'session_id' must not be optional" in it })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun requiredFieldTypedNullableIsRejected() {
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"session.events.since",
|
||||
NullableRequiredParams.serializer().descriptor,
|
||||
)
|
||||
assertEquals(
|
||||
1,
|
||||
problems.count { "required param 'session_id' must not be nullable unless schema allows null" in it },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun omittingRequiredProfileIsRejectedLikeAnyRequiredParam() {
|
||||
// bot_relay.deliver has required: ["profile", "message"]. Profile injection is skipped when no
|
||||
// profile is active, so `profile` gets no special treatment: the params class must declare it.
|
||||
val problems =
|
||||
GatewayContract.paramsDescriptorProblems(
|
||||
"bot_relay.deliver",
|
||||
DeliverMissingProfileParams.serializer().descriptor,
|
||||
)
|
||||
assertEquals(1, problems.count { "missing required param 'profile'" in it })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class PluginsProjectsTypesTest {
|
||||
private fun encode(params: PluginsManageParams) =
|
||||
OkHttpProvider.json.encodeToJsonElement(PluginsManageParams.serializer(), params)
|
||||
|
||||
@Test
|
||||
fun listEncodesOnlyAction() {
|
||||
assertEquals(buildJsonObject { put("action", "list") }, encode(PluginsManageParams(action = "list")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun removeEncodesActionAndName() {
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("action", "remove")
|
||||
put("name", "demo")
|
||||
},
|
||||
encode(PluginsManageParams(action = "remove", name = "demo")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsEncodesKeyValuesAndExplicitProfile() {
|
||||
val values = buildJsonObject { put("port", JsonPrimitive(8080)) }
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("action", "settings")
|
||||
put("profile", "staging")
|
||||
put("key", "camofox")
|
||||
put("values", values)
|
||||
},
|
||||
encode(PluginsManageParams(action = "settings", key = "camofox", values = values, profile = "staging")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun projectsListEncodesAsEmptyObject() {
|
||||
assertEquals(
|
||||
buildJsonObject { },
|
||||
OkHttpProvider.json.encodeToJsonElement(ProjectsListParams.serializer(), ProjectsListParams),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptorsAreRegisteredWithContractNames() {
|
||||
assertTrue(RpcMethods.PLUGINS_MANAGE in RpcMethods.all)
|
||||
assertTrue(RpcMethods.PROJECTS_LIST in RpcMethods.all)
|
||||
assertEquals("plugins.manage", RpcMethods.PLUGINS_MANAGE.name)
|
||||
assertEquals("projects.list", RpcMethods.PROJECTS_LIST.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProcessTypesTest {
|
||||
@Test
|
||||
fun killEncodesExactlySessionAndProcessIds() {
|
||||
val encoded =
|
||||
OkHttpProvider.json.encodeToJsonElement(
|
||||
ProcessKillParams.serializer(),
|
||||
ProcessKillParams(sessionId = "s1", processId = "p1"),
|
||||
)
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("process_id", "p1")
|
||||
},
|
||||
encoded,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopEncodesAnEmptyObjectAndDecodesKilledCount() {
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ProcessStopParams.serializer(), ProcessStopParams)
|
||||
assertEquals(buildJsonObject {}, encoded)
|
||||
val result = OkHttpProvider.json.decodeFromString(ProcessStopResult.serializer(), """{"killed":3}""")
|
||||
assertEquals(ProcessStopResult(killed = 3), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptorsAreRegisteredWithContractNames() {
|
||||
assertTrue(RpcMethods.PROCESS_STOP in RpcMethods.all)
|
||||
assertEquals("process.stop", RpcMethods.PROCESS_STOP.name)
|
||||
assertTrue(RpcMethods.PROCESS_LIST in RpcMethods.all)
|
||||
assertTrue(RpcMethods.PROCESS_KILL in RpcMethods.all)
|
||||
assertEquals("process.list", RpcMethods.PROCESS_LIST.name)
|
||||
assertEquals("process.kill", RpcMethods.PROCESS_KILL.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ProfilesTypesTest {
|
||||
private fun encode(params: ProfilesConfigureParams) =
|
||||
OkHttpProvider.json.encodeToJsonElement(ProfilesConfigureParams.serializer(), params)
|
||||
|
||||
@Test
|
||||
fun configureEncodesNameAndUiMetaOnly() {
|
||||
val meta = buildJsonObject { put("hermes-bots-groups", buildJsonObject { put("version", 3) }) }
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("name", "default")
|
||||
put("ui_meta", meta)
|
||||
},
|
||||
encode(ProfilesConfigureParams(name = "default", uiMeta = meta)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun configureIncludesSoulWhenPresent() {
|
||||
val meta = buildJsonObject { put("hermes-bots", buildJsonObject { put("title", "Scout") }) }
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("name", "scout")
|
||||
put("ui_meta", meta)
|
||||
put("soul", "# Scout")
|
||||
},
|
||||
encode(ProfilesConfigureParams(name = "scout", uiMeta = meta, soul = "# Scout")),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listEncodesAsEmptyObject() {
|
||||
assertEquals(
|
||||
buildJsonObject { },
|
||||
OkHttpProvider.json.encodeToJsonElement(ProfilesListParams.serializer(), ProfilesListParams),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptorsAreRegisteredWithContractNames() {
|
||||
assertTrue(RpcMethods.PROFILES_LIST in RpcMethods.all)
|
||||
assertTrue(RpcMethods.PROFILES_CONFIGURE in RpcMethods.all)
|
||||
assertEquals("profiles.list", RpcMethods.PROFILES_LIST.name)
|
||||
assertEquals("profiles.configure", RpcMethods.PROFILES_CONFIGURE.name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionControlTypesTest {
|
||||
@Test
|
||||
fun sessionInterruptParamsEncodesSessionIdExactly() {
|
||||
val params = SessionInterruptParams(sessionId = "sess-int-1")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionInterruptParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-int-1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCorrectionParamsEncodesSessionIdAndTextExactly() {
|
||||
val params = SessionCorrectionParams(sessionId = "sess-corr-1", text = "stop that action")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCorrectionParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-corr-1")
|
||||
put("text", "stop that action")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionInterruptResultDecodesFullPayloadAndIgnoresUnknownKeys() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("status", "interrupted")
|
||||
put("unknown_key", "ignored_value")
|
||||
put("extra_flag", true)
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionInterruptResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertEquals("interrupted", decoded.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionInterruptResultDecodesEmptyObjectToNulls() {
|
||||
val payload = buildJsonObject {}
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionInterruptResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCorrectionResultDecodesFullPayloadAndIgnoresUnknownKeys() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("status", "applied")
|
||||
put("text", "stop that action")
|
||||
put("ignored_num", 42)
|
||||
put("nested", buildJsonObject { put("ignored", "yes") })
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionCorrectionResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertEquals("applied", decoded.status)
|
||||
assertEquals("stop that action", decoded.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCorrectionResultDecodesEmptyObjectToNulls() {
|
||||
val payload = buildJsonObject {}
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionCorrectionResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.status)
|
||||
assertNull(decoded.text)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcMethodsRegistrationMatchesControlContract() {
|
||||
assertEquals("session.interrupt", RpcMethods.SESSION_INTERRUPT.name)
|
||||
assertEquals("session.steer", RpcMethods.SESSION_STEER.name)
|
||||
assertEquals("session.redirect", RpcMethods.SESSION_REDIRECT.name)
|
||||
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_INTERRUPT))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_STEER))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_REDIRECT))
|
||||
}
|
||||
}
|
||||
+126
@@ -0,0 +1,126 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
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 kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionEventsSinceTypesTest {
|
||||
@Test
|
||||
fun paramsWithLastSeenEncodesToExpectedJsonObject() {
|
||||
val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = 7)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("last_seen", 7)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun paramsWithNullLastSeenEncodesToSessionIdOnly() {
|
||||
val params = SessionEventsSinceParams(sessionId = "s1", lastSeen = null)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionEventsSinceParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultDecodesFullRealisticPayloadIgnoringUnknownKeys() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"events",
|
||||
kotlinx.serialization.json.buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("type", "message.token")
|
||||
put("seq", 1)
|
||||
put("session_id", "s1")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
put("latest_seq", 42)
|
||||
put("truncated", false)
|
||||
put("epoch", "ep-999")
|
||||
// Extra unknown keys from server that must be ignored
|
||||
put("count", 1)
|
||||
put(
|
||||
"open_requests",
|
||||
kotlinx.serialization.json.buildJsonArray {
|
||||
add(JsonPrimitive("req-1"))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionEventsSinceResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertEquals("ep-999", decoded.epoch)
|
||||
assertEquals(42, decoded.latestSeq)
|
||||
assertEquals(false, decoded.truncated)
|
||||
assertEquals(1, decoded.events?.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultDecodesEmptyObjectToAllNullDefaults() {
|
||||
val payload = buildJsonObject {}
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionEventsSinceResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.events)
|
||||
assertNull(decoded.latestSeq)
|
||||
assertNull(decoded.truncated)
|
||||
assertNull(decoded.epoch)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resultToleratesTruncatedAbsent() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("latest_seq", 10)
|
||||
put("epoch", "epoch-1")
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionEventsSinceResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.truncated)
|
||||
assertEquals(10, decoded.latestSeq)
|
||||
assertEquals("epoch-1", decoded.epoch)
|
||||
assertNull(decoded.events)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcMethodsRegistrationMatchesContract() {
|
||||
assertEquals("session.events.since", RpcMethods.SESSION_EVENTS_SINCE.name)
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_EVENTS_SINCE))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.encodeToJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionMiscTypesTest {
|
||||
@Test
|
||||
fun sessionListParamsEncodesToEmptyJsonObject() {
|
||||
val params = SessionListParams
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionListParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionIdParamsEncodesSessionIdExactly() {
|
||||
val params = SessionIdParams(sessionId = "sess-misc-1")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-misc-1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionBranchParamsEncodesSessionIdOnlyWhenNameNull() {
|
||||
val params = SessionBranchParams(sessionId = "sess-branch-1")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-branch-1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionBranchParamsEncodesSessionIdAndNameWhenPresent() {
|
||||
val params = SessionBranchParams(sessionId = "sess-branch-2", name = "experiment-fork")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-branch-2")
|
||||
put("name", "experiment-fork")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionBranchParamsPassesBlankNameThroughAsIs() {
|
||||
val params = SessionBranchParams(sessionId = "sess-branch-3", name = " ")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-branch-3")
|
||||
put("name", " ")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionBranchWholeParamsEncodesSessionIdOnlyWhenNameNull() {
|
||||
val params = SessionBranchWholeParams(sessionId = "sess-whole-1")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-whole-1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionBranchWholeParamsEncodesSessionIdAndNameWhenPresent() {
|
||||
val params = SessionBranchWholeParams(sessionId = "sess-whole-2", name = "full-tree-fork")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-whole-2")
|
||||
put("name", "full-tree-fork")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionBranchWholeParamsPassesBlankNameThroughAsIs() {
|
||||
val params = SessionBranchWholeParams(sessionId = "sess-whole-3", name = " \t ")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionBranchWholeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-whole-3")
|
||||
put("name", " \t ")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "name"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCompressParamsEncodesSessionIdOnlyWhenFocusTopicNull() {
|
||||
val params = SessionCompressParams(sessionId = "sess-comp-1")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-comp-1")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCompressParamsEncodesSessionIdAndFocusTopicWhenPresent() {
|
||||
val params = SessionCompressParams(sessionId = "sess-comp-2", focusTopic = "summarize-db-schema")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-comp-2")
|
||||
put("focus_topic", "summarize-db-schema")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCompressParamsPassesBlankFocusTopicThroughAsIs() {
|
||||
val params = SessionCompressParams(sessionId = "sess-comp-3", focusTopic = " ")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCompressParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-comp-3")
|
||||
put("focus_topic", " ")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "focus_topic"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptBtwParamsEncodesSessionIdAndText() {
|
||||
val params = PromptBtwParams(sessionId = "sess-btw-1", text = "by the way, take note")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptBtwParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-btw-1")
|
||||
put("text", "by the way, take note")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptBtwResultDecodesFullPayloadAndIgnoresUnknownKeys() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("task_id", "task-xyz-987")
|
||||
put("unknown_flag", true)
|
||||
put("extra_num", 100)
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
PromptBtwResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertEquals("task-xyz-987", decoded.taskId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptBtwResultDecodesEmptyObjectToNulls() {
|
||||
val payload = buildJsonObject {}
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
PromptBtwResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.taskId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcMethodsRegistrationMatchesContract() {
|
||||
assertEquals("session.list", RpcMethods.SESSION_LIST.name)
|
||||
assertEquals("session.branch", RpcMethods.SESSION_BRANCH.name)
|
||||
assertEquals("session.branch_whole", RpcMethods.SESSION_BRANCH_WHOLE.name)
|
||||
assertEquals("session.compress", RpcMethods.SESSION_COMPRESS.name)
|
||||
assertEquals("session.context_breakdown", RpcMethods.SESSION_CONTEXT_BREAKDOWN.name)
|
||||
assertEquals("session.usage", RpcMethods.SESSION_USAGE.name)
|
||||
assertEquals("prompt.btw", RpcMethods.PROMPT_BTW.name)
|
||||
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_LIST))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_BRANCH_WHOLE))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_COMPRESS))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CONTEXT_BREAKDOWN))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_USAGE))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_BTW))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun jsonElementPassthroughMethodsDecodeResultUntouched() {
|
||||
val arbitraryPayload =
|
||||
buildJsonObject {
|
||||
put("status", "ok")
|
||||
put("tokens", 4567)
|
||||
put("nested", buildJsonObject { put("item", "val") })
|
||||
}
|
||||
|
||||
val decodedList: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.SESSION_LIST.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
val decodedBranch: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.SESSION_BRANCH.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
val decodedBranchWhole: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.SESSION_BRANCH_WHOLE.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
val decodedCompress: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.SESSION_COMPRESS.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
val decodedBreakdown: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.SESSION_CONTEXT_BREAKDOWN.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
val decodedUsage: JsonElement =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
RpcMethods.SESSION_USAGE.result,
|
||||
arbitraryPayload,
|
||||
)
|
||||
|
||||
assertEquals(arbitraryPayload, decodedList)
|
||||
assertEquals(arbitraryPayload, decodedBranch)
|
||||
assertEquals(arbitraryPayload, decodedBranchWhole)
|
||||
assertEquals(arbitraryPayload, decodedCompress)
|
||||
assertEquals(arbitraryPayload, decodedBreakdown)
|
||||
assertEquals(arbitraryPayload, decodedUsage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
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 kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SessionPromptTypesTest {
|
||||
@Test
|
||||
fun createWithSourceOnlyEncodesExactlySource() {
|
||||
val params = SessionCreateParams(source = "cli")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("source", "cli")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("source"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun groupStyleCreateEncodesExpectedFourKeysWithHiddenBoolean() {
|
||||
val params =
|
||||
SessionCreateParams(
|
||||
profile = "work",
|
||||
title = "Team sync",
|
||||
source = "matrix",
|
||||
hidden = true,
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("source", "matrix")
|
||||
put("profile", "work")
|
||||
put("title", "Team sync")
|
||||
put("hidden", true)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
val hiddenElement = (encoded as JsonObject)["hidden"] as? JsonPrimitive
|
||||
assertTrue(hiddenElement != null && hiddenElement.isString.not())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allNullCreateParamsEncodeToEmptyObject() {
|
||||
val params = SessionCreateParams()
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
|
||||
|
||||
val expected = buildJsonObject {}
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertTrue((encoded as JsonObject).isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun createWithExplicitFalseHiddenIsEncoded() {
|
||||
val params = SessionCreateParams(hidden = false)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionCreateParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("hidden", false)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("hidden"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeEncodesSessionIdAndOmitMessagesExactly() {
|
||||
val params = SessionResumeParams(sessionId = "s-123", omitMessages = true)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s-123")
|
||||
put("omit_messages", true)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeWithProfileEncodesExpectedThreeKeys() {
|
||||
val params =
|
||||
SessionResumeParams(
|
||||
sessionId = "s-456",
|
||||
omitMessages = true,
|
||||
profile = "personal",
|
||||
)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s-456")
|
||||
put("omit_messages", true)
|
||||
put("profile", "personal")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "omit_messages", "profile"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun resumeWithExplicitFalseOmitMessagesIsEncoded() {
|
||||
val params = SessionResumeParams(sessionId = "s-789", omitMessages = false)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionResumeParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s-789")
|
||||
put("omit_messages", false)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "omit_messages"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitEncodesSessionIdAndText() {
|
||||
val params = PromptSubmitParams(sessionId = "s-100", text = "hello hermes")
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s-100")
|
||||
put("text", "hello hermes")
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "text"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitWithQueuedTrueEncodesQueued() {
|
||||
val params = PromptSubmitParams(sessionId = "s-101", text = "queued prompt", queued = true)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s-101")
|
||||
put("text", "queued prompt")
|
||||
put("queued", true)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun submitWithExplicitFalseQueuedIsEncoded() {
|
||||
val params = PromptSubmitParams(sessionId = "s-102", text = "direct prompt", queued = false)
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(PromptSubmitParams.serializer(), params)
|
||||
|
||||
val expected =
|
||||
buildJsonObject {
|
||||
put("session_id", "s-102")
|
||||
put("text", "direct prompt")
|
||||
put("queued", false)
|
||||
}
|
||||
|
||||
assertEquals(expected, encoded)
|
||||
assertTrue(encoded is JsonObject)
|
||||
assertEquals(setOf("session_id", "text", "queued"), (encoded as JsonObject).keys)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCreateResultDecodesFullPayloadAndIgnoresUnknownKeys() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-created-1")
|
||||
put("stored_session_id", "stored-1")
|
||||
put("created_at", 123456789)
|
||||
put("unknown_flag", true)
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionCreateResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertEquals("sess-created-1", decoded.sessionId)
|
||||
assertEquals("stored-1", decoded.storedSessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionCreateResultDecodesEmptyObjectToNulls() {
|
||||
val payload = buildJsonObject {}
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionCreateResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.sessionId)
|
||||
assertNull(decoded.storedSessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionResumeResultDecodesFullPayloadAndIgnoresUnknownKeys() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("session_id", "sess-resumed-1")
|
||||
put("active_model", "gemini")
|
||||
put("unread_count", 0)
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionResumeResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertEquals("sess-resumed-1", decoded.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sessionResumeResultDecodesEmptyObjectToNulls() {
|
||||
val payload = buildJsonObject {}
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
SessionResumeResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptSubmitResultDecodesFullPayloadAndIgnoresUnknownKeys() {
|
||||
val payload =
|
||||
buildJsonObject {
|
||||
put("status", "accepted")
|
||||
put("ticket_id", "tick-42")
|
||||
put("queued_at", "2026-09-29T10:00:00Z")
|
||||
}
|
||||
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
PromptSubmitResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertEquals("accepted", decoded.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun promptSubmitResultDecodesEmptyObjectToNulls() {
|
||||
val payload = buildJsonObject {}
|
||||
val decoded =
|
||||
OkHttpProvider.json.decodeFromJsonElement(
|
||||
PromptSubmitResult.serializer(),
|
||||
payload,
|
||||
)
|
||||
|
||||
assertNull(decoded.status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rpcMethodsRegistrationMatchesContract() {
|
||||
assertEquals("session.create", RpcMethods.SESSION_CREATE.name)
|
||||
assertEquals("session.resume", RpcMethods.SESSION_RESUME.name)
|
||||
assertEquals("prompt.submit", RpcMethods.PROMPT_SUBMIT.name)
|
||||
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_CREATE))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.SESSION_RESUME))
|
||||
assertTrue(RpcMethods.all.contains(RpcMethods.PROMPT_SUBMIT))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.m57.hermescontrol.data.ws.contract
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SubagentTypesTest {
|
||||
@Test
|
||||
fun listEncodesOnlySessionId() {
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(SessionIdParams.serializer(), SessionIdParams("s1"))
|
||||
assertEquals(buildJsonObject { put("session_id", "s1") }, encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun tailEncodesExactlyTheTwoRequiredIds() {
|
||||
val encoded =
|
||||
OkHttpProvider.json.encodeToJsonElement(
|
||||
SubagentTailParams.serializer(),
|
||||
SubagentTailParams("s1", "sub-1"),
|
||||
)
|
||||
assertEquals(
|
||||
buildJsonObject {
|
||||
put("session_id", "s1")
|
||||
put("subagent_id", "sub-1")
|
||||
},
|
||||
encoded,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun descriptorsAreRegisteredAndPreserveRawResults() {
|
||||
assertTrue(RpcMethods.SUBAGENT_LIST in RpcMethods.all)
|
||||
assertTrue(RpcMethods.SUBAGENT_TAIL in RpcMethods.all)
|
||||
assertEquals("subagent.list", RpcMethods.SUBAGENT_LIST.name)
|
||||
assertEquals("subagent.tail", RpcMethods.SUBAGENT_TAIL.name)
|
||||
val raw: JsonElement = buildJsonObject { put("future_field", "retained") }
|
||||
assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_LIST.result, raw))
|
||||
assertEquals(raw, OkHttpProvider.json.decodeFromJsonElement(RpcMethods.SUBAGENT_TAIL.result, raw))
|
||||
}
|
||||
}
|
||||
+55
@@ -287,6 +287,7 @@ class BackgroundConnectionControllerTest {
|
||||
every { AuthManager.isGatedMode() } returns true
|
||||
every { AuthManager.getToken() } returns null
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns true
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { ChatNotificationService.isAppInForeground() } returns false
|
||||
every { HermesWsClient.pendingReply } returns false
|
||||
@@ -320,6 +321,7 @@ class BackgroundConnectionControllerTest {
|
||||
every { AuthManager.isGatedMode() } returns false
|
||||
every { AuthManager.getToken() } returns null
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns true
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { ChatNotificationService.isAppInForeground() } returns false
|
||||
every { HermesWsClient.pendingReply } returns false
|
||||
@@ -352,6 +354,7 @@ class BackgroundConnectionControllerTest {
|
||||
every { AuthManager.isGatedMode() } returns false
|
||||
every { AuthManager.getToken() } returns "some-token"
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns true
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns true
|
||||
every { ChatNotificationService.isAppInForeground() } returns false
|
||||
every { HermesWsClient.pendingReply } returns false
|
||||
@@ -385,6 +388,7 @@ class BackgroundConnectionControllerTest {
|
||||
every { AuthManager.isGatedMode() } returns false
|
||||
every { AuthManager.getToken() } returns "some-token"
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns true
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.isAutoReconnect() } returns false
|
||||
every { ChatNotificationService.isAppInForeground() } returns false
|
||||
every { HermesWsClient.pendingReply } returns false
|
||||
@@ -407,4 +411,55 @@ class BackgroundConnectionControllerTest {
|
||||
unmockkObject(NetworkMonitor)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnKeepConnectedDisabled_whenBackgrounded_releasesLeaseAndStopsService() {
|
||||
var leaseReleased = false
|
||||
var serviceStopped = false
|
||||
val snapshot =
|
||||
BackgroundConnectionSnapshot(
|
||||
appInForeground = false,
|
||||
keepConnectedOptIn = false,
|
||||
pendingReply = false,
|
||||
isEligibleForConnection = true,
|
||||
status = ConnectionStatus.CONNECTED,
|
||||
)
|
||||
val controller =
|
||||
BackgroundConnectionController(
|
||||
snapshotProvider = { snapshot },
|
||||
releaseLease = { leaseReleased = true },
|
||||
requestServiceStop = { serviceStopped = true },
|
||||
)
|
||||
|
||||
controller.onKeepConnectedDisabled()
|
||||
|
||||
assertTrue(leaseReleased)
|
||||
assertTrue(serviceStopped)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testOnKeepConnectedDisabled_whenReplyPending_keepsServiceAndRefreshesNotification() {
|
||||
var serviceStopped = false
|
||||
var refreshed: BackgroundNotificationState? = null
|
||||
val snapshot =
|
||||
BackgroundConnectionSnapshot(
|
||||
appInForeground = false,
|
||||
keepConnectedOptIn = false,
|
||||
pendingReply = true,
|
||||
isEligibleForConnection = true,
|
||||
status = ConnectionStatus.CONNECTED,
|
||||
)
|
||||
val controller =
|
||||
BackgroundConnectionController(
|
||||
snapshotProvider = { snapshot },
|
||||
releaseLease = {},
|
||||
requestServiceStop = { serviceStopped = true },
|
||||
onNotificationStateChanged = { refreshed = it },
|
||||
)
|
||||
|
||||
controller.onKeepConnectedDisabled()
|
||||
|
||||
assertFalse(serviceStopped)
|
||||
assertEquals(BackgroundNotificationState.WaitingForReplies, refreshed)
|
||||
}
|
||||
}
|
||||
+42
@@ -250,4 +250,46 @@ class BackgroundConnectionPolicyTest {
|
||||
assertTrue(decision.shouldHoldPersistentLease)
|
||||
assertEquals(BackgroundNotificationState.Connecting, decision.notificationState)
|
||||
}
|
||||
|
||||
// ── Session-completion notify opt-in (implies the keep-connected lease) ──
|
||||
|
||||
@Test
|
||||
fun testBackground_notifyCompletionsOn_alone_holdsServiceAndLease() {
|
||||
val state =
|
||||
BackgroundConnectionSnapshot(
|
||||
appInForeground = false,
|
||||
isDeparting = false,
|
||||
keepConnectedOptIn = false,
|
||||
pendingReply = false,
|
||||
isEligibleForConnection = true,
|
||||
status = ConnectionStatus.CONNECTED,
|
||||
isAutoReconnect = true,
|
||||
hasActiveNetwork = true,
|
||||
notifyCompletionsOptIn = true,
|
||||
)
|
||||
val decision = BackgroundConnectionPolicy.evaluate(state)
|
||||
assertTrue(decision.shouldHoldService)
|
||||
assertTrue(decision.shouldHoldPersistentLease)
|
||||
assertEquals(BackgroundNotificationState.ConnectedInBackground, decision.notificationState)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testBackground_notifyCompletionsOff_pendingReplyOnly_holdsServiceWithoutLease() {
|
||||
val state =
|
||||
BackgroundConnectionSnapshot(
|
||||
appInForeground = false,
|
||||
isDeparting = false,
|
||||
keepConnectedOptIn = false,
|
||||
pendingReply = true,
|
||||
isEligibleForConnection = true,
|
||||
status = ConnectionStatus.CONNECTED,
|
||||
isAutoReconnect = true,
|
||||
hasActiveNetwork = true,
|
||||
notifyCompletionsOptIn = false,
|
||||
)
|
||||
val decision = BackgroundConnectionPolicy.evaluate(state)
|
||||
assertTrue(decision.shouldHoldService)
|
||||
assertFalse(decision.shouldHoldPersistentLease)
|
||||
assertEquals(BackgroundNotificationState.WaitingForReplies, decision.notificationState)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.content.Context
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import io.mockk.mockk
|
||||
import io.mockk.verify
|
||||
@@ -164,3 +165,116 @@ class ChatNotificationServiceTest {
|
||||
assertEquals("Answer\ncontinued", plan.correlationText)
|
||||
}
|
||||
}
|
||||
|
||||
class ChatNotificationServiceActiveListTest {
|
||||
@Test
|
||||
fun `parseActiveSessionLookup decodes the typed JSON result and maps ids plus title`() {
|
||||
val raw =
|
||||
"""
|
||||
{"sessions":[
|
||||
{"id":"rt1","session_key":"stored-1","title":"Nightly report","status":"idle","last_active":1780000000.0},
|
||||
{"id":"rt2","session_key":"stored-2","status":"idle"}
|
||||
]}
|
||||
""".trimIndent()
|
||||
|
||||
val found = parseActiveSessionLookup(raw, "rt1")
|
||||
assertEquals("stored-1", found?.storedId)
|
||||
assertEquals("Nightly report", found?.title)
|
||||
|
||||
// Row without a title: notification falls back to the app name.
|
||||
val untitled = parseActiveSessionLookup(raw, "rt2")
|
||||
assertEquals("stored-2", untitled?.storedId)
|
||||
assertNull(untitled?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseActiveSessionLookup accepts the JsonElement a typed RPC call returns`() {
|
||||
val raw = """{"sessions":[{"id":"rt9","session_key":"stored-9","title":"T"}]}"""
|
||||
val element =
|
||||
kotlinx.serialization.json.Json
|
||||
.parseToJsonElement(raw)
|
||||
|
||||
val found = parseActiveSessionLookup(element, "rt9")
|
||||
assertEquals("stored-9", found?.storedId)
|
||||
assertEquals("T", found?.title)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `parseActiveSessionLookup rejects unknown runtime ids and empty keys`() {
|
||||
val raw = """{"sessions":[{"id":"rt1","session_key":"","status":"idle"}]}"""
|
||||
|
||||
assertNull(parseActiveSessionLookup(raw, "rt1"))
|
||||
assertNull(parseActiveSessionLookup(raw, "missing"))
|
||||
assertNull(parseActiveSessionLookup(null, "rt1"))
|
||||
assertNull(parseActiveSessionLookup(raw, ""))
|
||||
assertNull(parseActiveSessionLookup("not json", "rt1"))
|
||||
}
|
||||
}
|
||||
|
||||
class MessageCompleteRouteTest {
|
||||
@Test
|
||||
fun `active session routes to the reply path`() {
|
||||
val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "rt1")
|
||||
assertEquals(MessageCompleteRoute.Reply, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle off keeps legacy behavior for foreign sessions`() {
|
||||
val route = messageCompleteRoute(holderSessionId = "rt1", toggleOn = false, eventSessionId = "other")
|
||||
assertEquals(MessageCompleteRoute.Reply, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle on routes foreign sessions to the plain alert`() {
|
||||
assertTrue(
|
||||
messageCompleteRoute(holderSessionId = "rt1", toggleOn = true, eventSessionId = "other") is
|
||||
MessageCompleteRoute.ForeignSession,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle on with a cleared holder routes the pinned alert`() {
|
||||
// After a background reconnect the holder is cleared: the user's own
|
||||
// reply lands in the plain alert (resolved stored id keeps the tap
|
||||
// working) instead of being dropped.
|
||||
assertTrue(
|
||||
messageCompleteRoute(holderSessionId = null, toggleOn = true, eventSessionId = "rt1") is
|
||||
MessageCompleteRoute.ForeignSession,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `toggle off with a cleared holder still uses the reply path`() {
|
||||
val route = messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1")
|
||||
assertEquals(MessageCompleteRoute.Reply, route)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy reply route retires the service when the wait is over`() {
|
||||
// Toggle off + holder cleared by a reconnect: the route is Reply, and the
|
||||
// Reply branch always ends in onReplyCompleted(generation).
|
||||
assertEquals(
|
||||
MessageCompleteRoute.Reply,
|
||||
messageCompleteRoute(holderSessionId = null, toggleOn = false, eventSessionId = "rt1"),
|
||||
)
|
||||
var completedGeneration: Long? = null
|
||||
val controller =
|
||||
BackgroundConnectionController(
|
||||
snapshotProvider = {
|
||||
BackgroundConnectionSnapshot(
|
||||
appInForeground = false,
|
||||
isDeparting = false,
|
||||
keepConnectedOptIn = false,
|
||||
pendingReply = true,
|
||||
isEligibleForConnection = true,
|
||||
status = ConnectionStatus.CONNECTED,
|
||||
isAutoReconnect = true,
|
||||
hasActiveNetwork = true,
|
||||
)
|
||||
},
|
||||
requestServiceComplete = { gen -> completedGeneration = gen },
|
||||
)
|
||||
controller.onReplyCompleted(7L)
|
||||
assertEquals(7L, completedGeneration)
|
||||
}
|
||||
}
|
||||
+20
-10
@@ -21,6 +21,7 @@ import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -108,7 +109,7 @@ class NotificationReplyReceiverTest {
|
||||
val params = arg<Map<String, Any>>(1)
|
||||
val result =
|
||||
if (method == WsMethods.SESSION_RESUME) {
|
||||
mapOf("session_id" to "runtime-${params["session_id"]}")
|
||||
mapOf("session_id" to "runtime-${params.str("session_id")}")
|
||||
} else {
|
||||
mapOf("accepted" to true)
|
||||
}
|
||||
@@ -147,7 +148,7 @@ class NotificationReplyReceiverTest {
|
||||
verify {
|
||||
HermesWsClient.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
match { it["session_id"] == "runtime-session-abc" && it["text"] == "Hello" },
|
||||
match { it.str("session_id") == "runtime-session-abc" && it.str("text") == "Hello" },
|
||||
any(),
|
||||
)
|
||||
}
|
||||
@@ -329,19 +330,19 @@ class NotificationReplyReceiverTest {
|
||||
verify {
|
||||
HermesWsClient.request(
|
||||
WsMethods.SESSION_RESUME,
|
||||
match { it["session_id"] == "session-abc" && it["omit_messages"] == true },
|
||||
match { it.str("session_id") == "session-abc" && it["omit_messages"] == JsonPrimitive(true) },
|
||||
any(),
|
||||
)
|
||||
HermesWsClient.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
match { it["session_id"] == "runtime-session-abc" },
|
||||
match { it.str("session_id") == "runtime-session-abc" },
|
||||
any(),
|
||||
)
|
||||
}
|
||||
verify(inverse = true) {
|
||||
HermesWsClient.request(
|
||||
WsMethods.PROMPT_SUBMIT,
|
||||
match { it["session_id"] == "stale-runtime" },
|
||||
match { it.str("session_id") == "stale-runtime" },
|
||||
any(),
|
||||
)
|
||||
}
|
||||
@@ -412,16 +413,23 @@ class NotificationReplyReceiverTest {
|
||||
kotlinx.coroutines.delay(10000)
|
||||
}
|
||||
|
||||
receiver.onReceive(mockContext, mockIntent)
|
||||
// Same timeout path as production, with a short deadline instead of 5s.
|
||||
val shortTimeoutReceiver =
|
||||
object : NotificationReplyReceiver() {
|
||||
override fun goAsyncCompat(): BroadcastReceiver.PendingResult = mockPendingResult
|
||||
|
||||
// Wait longer than the 5-second timeout, but less than the 10-second delay
|
||||
Thread.sleep(6000)
|
||||
override fun buildReplyNotification(context: Context): Notification = mockNotification
|
||||
|
||||
override val persistenceTimeoutMs: Long = 50L
|
||||
}
|
||||
|
||||
shortTimeoutReceiver.onReceive(mockContext, mockIntent)
|
||||
|
||||
// Timeout should be caught in catch block and logged
|
||||
verify { android.util.Log.e("NotificationReply", any<String>(), any()) }
|
||||
verify(timeout = 3_000) { android.util.Log.e("NotificationReply", any<String>(), any()) }
|
||||
|
||||
// Pending result must still finish after timeout
|
||||
verify { mockPendingResult.finish() }
|
||||
verify(timeout = 3_000) { mockPendingResult.finish() }
|
||||
}
|
||||
|
||||
// ── Helpers ────────────────────────────────────────────────────────────
|
||||
@@ -447,3 +455,5 @@ class NotificationReplyReceiverTest {
|
||||
every { mockIntent.getStringExtra(NotificationReplyReceiver.EXTRA_SESSION_ID) } returns sessionId
|
||||
}
|
||||
}
|
||||
|
||||
private fun Map<String, Any>.str(key: String): String? = (this[key] as? JsonPrimitive)?.content ?: this[key] as? String
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Enabled-state contract for the pinned Material 3 defaults, verified again by the device gallery. */
|
||||
class ThemeComponentContrastTest {
|
||||
@Test
|
||||
fun everyResolvedPresetSupportsEnabledComponentStates() {
|
||||
ThemeRegistry.forEach { (preset, _) ->
|
||||
listOf(true, false).forEach { dark ->
|
||||
val c = resolveColorScheme(preset, dark)
|
||||
// PR #1417: these are component-state combinations, not just on-color pairs.
|
||||
val graphics =
|
||||
listOf(
|
||||
"switch on thumb/track" to (c.onPrimary to c.primary),
|
||||
"switch on track/surface" to (c.primary to c.surface),
|
||||
"switch off thumb/track" to (c.outline to c.surfaceContainerHighest),
|
||||
"switch off border/surface" to (c.outline to c.surface),
|
||||
"radio selected/surface" to (c.primary to c.surface),
|
||||
"radio unselected/surface" to (c.onSurfaceVariant to c.surface),
|
||||
"checkbox selected/surface" to (c.primary to c.surface),
|
||||
"checkbox checkmark/box" to (c.onPrimary to c.primary),
|
||||
"checkbox unselected/surface" to (c.onSurfaceVariant to c.surface),
|
||||
"slider active/inactive" to (c.primary to c.secondaryContainer),
|
||||
"field normal border/surface" to (c.outline to c.surface),
|
||||
"field focused border/surface" to (c.primary to c.surface),
|
||||
"field error border/surface" to (c.error to c.surface),
|
||||
"segment selected border/container" to (c.outline to c.secondaryContainer),
|
||||
"segment unselected border/surface" to (c.outline to c.surface),
|
||||
)
|
||||
val text =
|
||||
listOf(
|
||||
"primary foreground/surface" to (c.primary to c.surface),
|
||||
"primary foreground/background" to (c.primary to c.background),
|
||||
"error foreground/surface" to (c.error to c.surface),
|
||||
"error foreground/background" to (c.error to c.background),
|
||||
"field normal label/surface" to (c.onSurfaceVariant to c.surface),
|
||||
"field focused label/surface" to (c.primary to c.surface),
|
||||
"field error label/surface" to (c.error to c.surface),
|
||||
"segment selected content/container" to (c.onSecondaryContainer to c.secondaryContainer),
|
||||
"segment unselected content/surface" to (c.onSurface to c.surface),
|
||||
)
|
||||
graphics.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 3f) }
|
||||
text.forEach { (name, pair) -> checkPair(preset, dark, name, pair, 4.5f) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPair(
|
||||
preset: ThemePreset,
|
||||
dark: Boolean,
|
||||
name: String,
|
||||
pair: Pair<Color, Color>,
|
||||
minimum: Float,
|
||||
) {
|
||||
val a = pair.first.luminance()
|
||||
val b = pair.second.luminance()
|
||||
val ratio = (maxOf(a, b) + 0.05f) / (minOf(a, b) + 0.05f)
|
||||
assertTrue("$preset dark=$dark $name is $ratio, expected >= $minimum", ratio >= minimum)
|
||||
}
|
||||
}
|
||||
@@ -1,67 +1,164 @@
|
||||
package com.m57.hermescontrol.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import com.m57.hermescontrol.theme.presets.AmoledTheme
|
||||
import com.m57.hermescontrol.theme.presets.CatppuccinTheme
|
||||
import com.m57.hermescontrol.theme.presets.DefaultTheme
|
||||
import com.m57.hermescontrol.theme.presets.GruvboxTheme
|
||||
import com.m57.hermescontrol.theme.presets.MonochromeTheme
|
||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
||||
import com.m57.hermescontrol.ui.common.statusBadgeColors
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
/**
|
||||
* Guards the palette template's color invariants:
|
||||
* 1. Every shipped error slot pair meets >= 3:1 WCAG contrast.
|
||||
* 1. Normal-text pairs meet >= 4.5:1 contrast in every resolved mode.
|
||||
* 2. ThemeMode combinations are validated at construction time.
|
||||
* 3. One-mode themes resolve and fall back to the default theme correctly.
|
||||
* 3. Every Material role is explicitly mapped; fixed roles are mode-independent.
|
||||
* 4. One-mode themes resolve and fall back to the default theme correctly.
|
||||
*/
|
||||
class ThemePaletteTest {
|
||||
private val themes =
|
||||
listOf(
|
||||
ThemePreset.DEFAULT to DefaultTheme,
|
||||
ThemePreset.MONOCHROME to MonochromeTheme,
|
||||
ThemePreset.GRUVBOX to GruvboxTheme,
|
||||
ThemePreset.CATPPUCCIN to CatppuccinTheme,
|
||||
ThemePreset.AMOLED to AmoledTheme,
|
||||
)
|
||||
@Test
|
||||
fun registryCoversEveryPresetExactlyOnce() {
|
||||
assertEquals(ThemePreset.entries, ThemeRegistry.map { it.preset })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun errorSlotPairsMeetContrastInEveryShippedMode() {
|
||||
themes.forEach { (preset, theme) ->
|
||||
fun textSlotPairsMeetContrastInEveryResolvedMode() {
|
||||
ThemeRegistry.forEach { (preset, _) ->
|
||||
listOf(true, false).forEach { dark ->
|
||||
val scheme = theme.schemeFor(dark) ?: return@forEach
|
||||
assertTrue(
|
||||
"$preset dark=$dark onError/error contrast must be >= 3:1",
|
||||
contrast(scheme.onError, scheme.error) >= 3f,
|
||||
)
|
||||
assertTrue(
|
||||
"$preset dark=$dark onErrorContainer/errorContainer contrast must be >= 3:1",
|
||||
contrast(scheme.onErrorContainer, scheme.errorContainer) >= 3f,
|
||||
)
|
||||
val c = resolveColorScheme(preset, dark)
|
||||
val pairs =
|
||||
listOf(
|
||||
"onPrimary/primary" to (c.onPrimary to c.primary),
|
||||
"onPrimaryContainer/primaryContainer" to (c.onPrimaryContainer to c.primaryContainer),
|
||||
"onSecondary/secondary" to (c.onSecondary to c.secondary),
|
||||
"onSecondaryContainer/secondaryContainer" to (c.onSecondaryContainer to c.secondaryContainer),
|
||||
"onTertiary/tertiary" to (c.onTertiary to c.tertiary),
|
||||
"onTertiaryContainer/tertiaryContainer" to (c.onTertiaryContainer to c.tertiaryContainer),
|
||||
"onBackground/background" to (c.onBackground to c.background),
|
||||
"onSurface/surface" to (c.onSurface to c.surface),
|
||||
"onSurfaceVariant/surfaceVariant" to (c.onSurfaceVariant to c.surfaceVariant),
|
||||
"onSurface/surfaceDim" to (c.onSurface to c.surfaceDim),
|
||||
"onSurface/surfaceBright" to (c.onSurface to c.surfaceBright),
|
||||
"onSurface/surfaceContainerLowest" to (c.onSurface to c.surfaceContainerLowest),
|
||||
"onSurface/surfaceContainerLow" to (c.onSurface to c.surfaceContainerLow),
|
||||
"onSurface/surfaceContainer" to (c.onSurface to c.surfaceContainer),
|
||||
"onSurface/surfaceContainerHigh" to (c.onSurface to c.surfaceContainerHigh),
|
||||
"onSurface/surfaceContainerHighest" to (c.onSurface to c.surfaceContainerHighest),
|
||||
"inverseOnSurface/inverseSurface" to (c.inverseOnSurface to c.inverseSurface),
|
||||
"onError/error" to (c.onError to c.error),
|
||||
"onErrorContainer/errorContainer" to (c.onErrorContainer to c.errorContainer),
|
||||
"onPrimaryFixed/primaryFixed" to (c.onPrimaryFixed to c.primaryFixed),
|
||||
"onPrimaryFixedVariant/primaryFixed" to (c.onPrimaryFixedVariant to c.primaryFixed),
|
||||
"onPrimaryFixed/primaryFixedDim" to (c.onPrimaryFixed to c.primaryFixedDim),
|
||||
"onPrimaryFixedVariant/primaryFixedDim" to (c.onPrimaryFixedVariant to c.primaryFixedDim),
|
||||
"onSecondaryFixed/secondaryFixed" to (c.onSecondaryFixed to c.secondaryFixed),
|
||||
"onSecondaryFixedVariant/secondaryFixed" to (c.onSecondaryFixedVariant to c.secondaryFixed),
|
||||
"onSecondaryFixed/secondaryFixedDim" to (c.onSecondaryFixed to c.secondaryFixedDim),
|
||||
"onSecondaryFixedVariant/secondaryFixedDim" to
|
||||
(c.onSecondaryFixedVariant to c.secondaryFixedDim),
|
||||
"onTertiaryFixed/tertiaryFixed" to (c.onTertiaryFixed to c.tertiaryFixed),
|
||||
"onTertiaryFixedVariant/tertiaryFixed" to (c.onTertiaryFixedVariant to c.tertiaryFixed),
|
||||
"onTertiaryFixed/tertiaryFixedDim" to (c.onTertiaryFixed to c.tertiaryFixedDim),
|
||||
"onTertiaryFixedVariant/tertiaryFixedDim" to (c.onTertiaryFixedVariant to c.tertiaryFixedDim),
|
||||
)
|
||||
pairs.forEach { (name, colors) ->
|
||||
val ratio = contrast(colors.first, colors.second)
|
||||
assertTrue("$preset dark=$dark $name contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renderedStatusBadgePairsMeetContrastInEveryResolvedMode() {
|
||||
ThemeRegistry.forEach { (preset, _) ->
|
||||
listOf(true, false).forEach { dark ->
|
||||
val colors = resolveStatusColors(preset, dark)
|
||||
StatusBadgeType.entries.filter { it != StatusBadgeType.NEUTRAL }.forEach { type ->
|
||||
// PR #1417: test the mapping consumed by both reusable badge renderers.
|
||||
val (background, foreground) = requireNotNull(statusBadgeColors(type, colors))
|
||||
val ratio = contrast(foreground, background)
|
||||
assertTrue("$preset dark=$dark $type badge contrast $ratio must be >= 4.5:1", ratio >= 4.5f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun statusBadgesUseMatchingStatusFillsAndOnColors() {
|
||||
val c = dummyColors().status
|
||||
assertEquals(c.success to c.onSuccess, statusBadgeColors(StatusBadgeType.SUCCESS, c))
|
||||
assertEquals(c.warning to c.onWarning, statusBadgeColors(StatusBadgeType.WARNING, c))
|
||||
assertEquals(c.error to c.onError, statusBadgeColors(StatusBadgeType.ERROR, c))
|
||||
assertEquals(c.info to c.onInfo, statusBadgeColors(StatusBadgeType.INFO, c))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fixedRolesDoNotChangeBetweenShippedModes() {
|
||||
ThemeRegistry.forEach { (preset, theme) ->
|
||||
val dark = theme.darkScheme
|
||||
val light = theme.lightScheme
|
||||
if (dark != null && light != null) {
|
||||
val darkRoles = colorFields(dark).filterKeys { "Fixed" in it }
|
||||
val lightRoles = colorFields(light).filterKeys { "Fixed" in it }
|
||||
assertEquals("$preset fixed roles must be mode-independent", darkRoles, lightRoles)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun everyMaterialColorRoleIsMappedFromThePalette() {
|
||||
// Distinct sentinel values catch swapped roles and accidental Material defaults.
|
||||
val colors = dummyColors()
|
||||
val expected = colorFields(colors).toMutableMap()
|
||||
expected["surfaceTint"] = expected.getValue("primary")
|
||||
expected.putAll(
|
||||
colorFields(colors.status).filterKeys {
|
||||
it in
|
||||
setOf(
|
||||
"error",
|
||||
"onError",
|
||||
"errorContainer",
|
||||
"onErrorContainer",
|
||||
)
|
||||
},
|
||||
)
|
||||
val theme = buildTheme(colors, colors)
|
||||
listOf(theme.darkScheme, theme.lightScheme).forEach { scheme ->
|
||||
assertEquals(expected, colorFields(requireNotNull(scheme)))
|
||||
}
|
||||
}
|
||||
|
||||
private fun colorFields(value: Any): Map<String, Long> =
|
||||
value.javaClass.declaredFields
|
||||
.filter {
|
||||
it.type == java.lang.Long.TYPE &&
|
||||
!Modifier.isStatic(it.modifiers)
|
||||
}.associate { field ->
|
||||
field.isAccessible = true
|
||||
field.name to field.getLong(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Full-bleed chat renderer gate (issue #866): agent prose renders directly
|
||||
* on the screen background (HermesScaffold containerColor = background),
|
||||
* so the full-bleed text pairs must hold against [ColorScheme.background]:
|
||||
* - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text
|
||||
* - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI
|
||||
* (The header deliberately avoids `primary`: Nord's light mode reuses its
|
||||
* pastel Frost accent as primary, which cannot reach 3:1 on a light
|
||||
* background.)
|
||||
* Iterates ThemePreset.entries directly so EVERY shipped preset (incl.
|
||||
* NORD, which the [themes] list above predates) is covered.
|
||||
* Header text uses onSurfaceVariant independently of the theme's accent.
|
||||
* The registry covers every shipped preset and its fallback mode.
|
||||
*/
|
||||
@Test
|
||||
fun fullBleedTextPairsMeetContrastInEveryShippedMode() {
|
||||
ThemePreset.entries.forEach { preset ->
|
||||
ThemeRegistry.forEach { (preset, _) ->
|
||||
listOf(true, false).forEach { dark ->
|
||||
val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach
|
||||
val scheme = resolveColorScheme(preset, darkTheme = dark)
|
||||
assertTrue(
|
||||
"$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)",
|
||||
contrast(scheme.onSurface, scheme.background) >= 4.5f,
|
||||
@@ -84,18 +181,18 @@ class ThemePaletteTest {
|
||||
|
||||
@Test
|
||||
fun everyPresetResolvesItsDeclaredModes() {
|
||||
themes.forEach { (preset, theme) ->
|
||||
ThemeRegistry.forEach { (preset, theme) ->
|
||||
listOf(true, false).forEach { dark ->
|
||||
if (theme.schemeFor(dark) != null) {
|
||||
assertTrue(
|
||||
"$preset dark=$dark scheme must resolve",
|
||||
resolveColorScheme(preset, darkTheme = dark) != null,
|
||||
)
|
||||
assertTrue(
|
||||
"$preset dark=$dark status must resolve",
|
||||
resolveStatusColors(preset, darkTheme = dark) != null,
|
||||
)
|
||||
}
|
||||
assertSame(
|
||||
"$preset dark=$dark must resolve its palette or Default fallback",
|
||||
theme.schemeFor(dark) ?: DefaultTheme.schemeFor(dark),
|
||||
resolveColorScheme(preset, dark),
|
||||
)
|
||||
assertSame(
|
||||
"$preset dark=$dark must resolve its status or Default fallback",
|
||||
theme.statusFor(dark) ?: DefaultTheme.statusFor(dark),
|
||||
resolveStatusColors(preset, dark),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -169,50 +266,64 @@ class ThemePaletteTest {
|
||||
|
||||
private fun dummyColors(): PaletteColors =
|
||||
PaletteColors(
|
||||
primary = Color.White,
|
||||
onPrimary = Color.Black,
|
||||
primaryContainer = Color.White,
|
||||
onPrimaryContainer = Color.Black,
|
||||
secondary = Color.White,
|
||||
onSecondary = Color.Black,
|
||||
secondaryContainer = Color.White,
|
||||
onSecondaryContainer = Color.Black,
|
||||
tertiary = Color.White,
|
||||
onTertiary = Color.Black,
|
||||
tertiaryContainer = Color.White,
|
||||
onTertiaryContainer = Color.Black,
|
||||
background = Color.White,
|
||||
onBackground = Color.Black,
|
||||
surface = Color.White,
|
||||
onSurface = Color.Black,
|
||||
surfaceVariant = Color.White,
|
||||
onSurfaceVariant = Color.Black,
|
||||
surfaceContainerLowest = Color.White,
|
||||
surfaceContainerLow = Color.White,
|
||||
surfaceContainer = Color.White,
|
||||
surfaceContainerHigh = Color.White,
|
||||
surfaceContainerHighest = Color.White,
|
||||
inverseSurface = Color.Black,
|
||||
inverseOnSurface = Color.White,
|
||||
inversePrimary = Color.Black,
|
||||
outline = Color.Gray,
|
||||
outlineVariant = Color.Gray,
|
||||
scrim = Color.Black,
|
||||
primary = Color(0xFF000000L + 1),
|
||||
onPrimary = Color(0xFF000000L + 2),
|
||||
primaryContainer = Color(0xFF000000L + 3),
|
||||
onPrimaryContainer = Color(0xFF000000L + 4),
|
||||
secondary = Color(0xFF000000L + 5),
|
||||
onSecondary = Color(0xFF000000L + 6),
|
||||
secondaryContainer = Color(0xFF000000L + 7),
|
||||
onSecondaryContainer = Color(0xFF000000L + 8),
|
||||
tertiary = Color(0xFF000000L + 9),
|
||||
onTertiary = Color(0xFF000000L + 10),
|
||||
tertiaryContainer = Color(0xFF000000L + 11),
|
||||
onTertiaryContainer = Color(0xFF000000L + 12),
|
||||
background = Color(0xFF000000L + 13),
|
||||
onBackground = Color(0xFF000000L + 14),
|
||||
surface = Color(0xFF000000L + 15),
|
||||
onSurface = Color(0xFF000000L + 16),
|
||||
surfaceVariant = Color(0xFF000000L + 17),
|
||||
onSurfaceVariant = Color(0xFF000000L + 18),
|
||||
surfaceDim = Color(0xFF000000L + 19),
|
||||
surfaceBright = Color(0xFF000000L + 20),
|
||||
primaryFixed = Color(0xFF000000L + 21),
|
||||
primaryFixedDim = Color(0xFF000000L + 22),
|
||||
onPrimaryFixed = Color(0xFF000000L + 23),
|
||||
onPrimaryFixedVariant = Color(0xFF000000L + 24),
|
||||
secondaryFixed = Color(0xFF000000L + 25),
|
||||
secondaryFixedDim = Color(0xFF000000L + 26),
|
||||
onSecondaryFixed = Color(0xFF000000L + 27),
|
||||
onSecondaryFixedVariant = Color(0xFF000000L + 28),
|
||||
tertiaryFixed = Color(0xFF000000L + 29),
|
||||
tertiaryFixedDim = Color(0xFF000000L + 30),
|
||||
onTertiaryFixed = Color(0xFF000000L + 31),
|
||||
onTertiaryFixedVariant = Color(0xFF000000L + 32),
|
||||
surfaceContainerLowest = Color(0xFF000000L + 33),
|
||||
surfaceContainerLow = Color(0xFF000000L + 34),
|
||||
surfaceContainer = Color(0xFF000000L + 35),
|
||||
surfaceContainerHigh = Color(0xFF000000L + 36),
|
||||
surfaceContainerHighest = Color(0xFF000000L + 37),
|
||||
inverseSurface = Color(0xFF000000L + 38),
|
||||
inverseOnSurface = Color(0xFF000000L + 39),
|
||||
inversePrimary = Color(0xFF000000L + 40),
|
||||
outline = Color(0xFF000000L + 41),
|
||||
outlineVariant = Color(0xFF000000L + 42),
|
||||
scrim = Color(0xFF000000L + 43),
|
||||
status =
|
||||
HermesStatusColors(
|
||||
success = Color.Green,
|
||||
successContainer = Color.White,
|
||||
onSuccess = Color.Black,
|
||||
warning = Color.Yellow,
|
||||
warningContainer = Color.White,
|
||||
onWarning = Color.Black,
|
||||
error = Color.Red,
|
||||
errorContainer = Color.White,
|
||||
onError = Color.Black,
|
||||
onErrorContainer = Color.Black,
|
||||
info = Color.Blue,
|
||||
infoContainer = Color.White,
|
||||
onInfo = Color.Black,
|
||||
success = Color(0xFF000000L + 100),
|
||||
successContainer = Color(0xFF000000L + 101),
|
||||
onSuccess = Color(0xFF000000L + 102),
|
||||
warning = Color(0xFF000000L + 103),
|
||||
warningContainer = Color(0xFF000000L + 104),
|
||||
onWarning = Color(0xFF000000L + 105),
|
||||
error = Color(0xFF000000L + 106),
|
||||
errorContainer = Color(0xFF000000L + 107),
|
||||
onError = Color(0xFF000000L + 108),
|
||||
onErrorContainer = Color(0xFF000000L + 109),
|
||||
info = Color(0xFF000000L + 110),
|
||||
infoContainer = Color(0xFF000000L + 111),
|
||||
onInfo = Color(0xFF000000L + 112),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class BrowserReturnTrackerTest {
|
||||
@Test
|
||||
fun `browser return requires a departure and consumes the lease once`() {
|
||||
val tracker = BrowserReturnTracker()
|
||||
tracker.start("operation-1")
|
||||
assertNull(tracker.onResume())
|
||||
tracker.onPause()
|
||||
assertEquals("operation-1", tracker.onResume()?.operationId)
|
||||
assertNull(tracker.onResume())
|
||||
assertFalse(tracker.abandon())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `legacy browser without operation id still reports return`() {
|
||||
val tracker = BrowserReturnTracker()
|
||||
tracker.start()
|
||||
tracker.onPause()
|
||||
assertTrue(tracker.onResume() != null)
|
||||
assertNull(tracker.onResume())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `failed launch clears tracking and does not release a departed lease`() {
|
||||
val tracker = BrowserReturnTracker()
|
||||
tracker.start("operation-1")
|
||||
tracker.onPause()
|
||||
tracker.cancel()
|
||||
assertNull(tracker.onResume())
|
||||
assertFalse(tracker.abandon())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non configuration disposal releases only a departed lease and clears the id`() {
|
||||
val tracker = BrowserReturnTracker()
|
||||
tracker.start("operation-1")
|
||||
assertFalse(tracker.abandon())
|
||||
tracker.start("operation-2")
|
||||
tracker.onPause()
|
||||
assertTrue(tracker.abandon())
|
||||
assertFalse(tracker.abandon())
|
||||
assertNull(tracker.onResume())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new launch resets departed state from the previous launch`() {
|
||||
val tracker = BrowserReturnTracker()
|
||||
tracker.start("first")
|
||||
tracker.onPause()
|
||||
tracker.start("second")
|
||||
assertNull(tracker.onResume())
|
||||
tracker.onPause()
|
||||
assertEquals("second", tracker.onResume()?.operationId)
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethod
|
||||
import com.m57.hermescontrol.data.ws.contract.TypedRpcSender
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
@@ -9,6 +12,8 @@ import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -25,7 +30,7 @@ class ChatApprovalsDelegateTest {
|
||||
private val uiState = MutableStateFlow(ChatUiState(currentSessionId = "storage-session-1"))
|
||||
private var runtimeId: String? = null
|
||||
private val sentMethods = mutableListOf<String>()
|
||||
private val sentParams = mutableListOf<Map<String, Any>>()
|
||||
private val sentParams = mutableListOf<JsonObject>()
|
||||
private val trackedRequests = mutableListOf<Pair<String, String>>()
|
||||
private val systemMessages = mutableListOf<String>()
|
||||
|
||||
@@ -35,11 +40,22 @@ class ChatApprovalsDelegateTest {
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
runtimeSessionId = { runtimeId },
|
||||
wsSend = { method, params, onSent ->
|
||||
sentMethods.add(method)
|
||||
sentParams.add(params)
|
||||
onSent?.invoke("req-id-${sentMethods.size}")
|
||||
},
|
||||
rpc =
|
||||
object : TypedRpcSender {
|
||||
override fun <P> send(
|
||||
method: RpcMethod<P, *>,
|
||||
params: P,
|
||||
onSent: ((String) -> Unit)?,
|
||||
): String {
|
||||
sentMethods.add(method.name)
|
||||
sentParams.add(
|
||||
OkHttpProvider.json.encodeToJsonElement(method.params, params) as JsonObject,
|
||||
)
|
||||
val id = "req-id-${sentMethods.size}"
|
||||
onSent?.invoke(id)
|
||||
return id
|
||||
}
|
||||
},
|
||||
trackRequest = { id, method -> trackedRequests.add(id to method) },
|
||||
addSystemMessage = { text -> systemMessages.add(text) },
|
||||
)
|
||||
@@ -98,8 +114,8 @@ class ChatApprovalsDelegateTest {
|
||||
assertFalse(uiState.value.isAgentTyping)
|
||||
|
||||
assertEquals(listOf(WsMethods.APPROVAL_RECEIVED), sentMethods)
|
||||
assertEquals("runtime-session-xyz", sentParams.first()["session_id"])
|
||||
assertEquals("req-123", sentParams.first()["request_id"])
|
||||
assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams.first()["session_id"])
|
||||
assertEquals(JsonPrimitive("req-123"), sentParams.first()["request_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -125,11 +141,11 @@ class ChatApprovalsDelegateTest {
|
||||
|
||||
advanceUntilIdle()
|
||||
assertEquals(listOf(WsMethods.APPROVAL_RESPOND, WsMethods.APPROVAL_PENDING), sentMethods)
|
||||
assertEquals("once", sentParams[0]["choice"])
|
||||
assertEquals("req-999", sentParams[0]["request_id"])
|
||||
assertEquals(false, sentParams[0]["all"])
|
||||
assertEquals("runtime-session-xyz", sentParams[0]["session_id"])
|
||||
assertEquals("runtime-session-xyz", sentParams[1]["session_id"])
|
||||
assertEquals(JsonPrimitive("once"), sentParams[0]["choice"])
|
||||
assertEquals(JsonPrimitive("req-999"), sentParams[0]["request_id"])
|
||||
assertEquals(JsonPrimitive(false), sentParams[0]["all"])
|
||||
assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams[0]["session_id"])
|
||||
assertEquals(JsonPrimitive("runtime-session-xyz"), sentParams[1]["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -270,7 +286,14 @@ class ChatApprovalsDelegateTest {
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
runtimeSessionId = { "session-1" },
|
||||
wsSend = { _, _, _ -> error("new approval must not use legacy response") },
|
||||
rpc =
|
||||
object : TypedRpcSender {
|
||||
override fun <P> send(
|
||||
method: RpcMethod<P, *>,
|
||||
params: P,
|
||||
onSent: ((String) -> Unit)?,
|
||||
): String = error("new approval must not use legacy response")
|
||||
},
|
||||
trackRequest = { _, _ -> },
|
||||
addSystemMessage = { },
|
||||
respondToServerRequest = { id, result -> responses += id to result },
|
||||
|
||||
@@ -201,7 +201,7 @@ class ChatCompressRpcTest {
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
|
||||
}
|
||||
|
||||
@@ -224,8 +224,8 @@ class ChatCompressRpcTest {
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("auth decisions", params["focus_topic"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
assertEquals(JsonPrimitive("auth decisions"), params["focus_topic"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -247,7 +247,7 @@ class ChatCompressRpcTest {
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
assertFalse("focus_topic should be omitted when blank", params.containsKey("focus_topic"))
|
||||
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||
}
|
||||
@@ -271,8 +271,8 @@ class ChatCompressRpcTest {
|
||||
val compressIndex = methodCalls.indexOf(WsMethods.SESSION_COMPRESS)
|
||||
assertTrue("expected SESSION_COMPRESS in $methodCalls", compressIndex >= 0)
|
||||
val params = paramsCalls[compressIndex]
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("architecture details", params["focus_topic"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
assertEquals(JsonPrimitive("architecture details"), params["focus_topic"])
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+172
-36
@@ -6,8 +6,13 @@ import com.m57.hermescontrol.data.model.ConnectionOperationTarget
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetAction
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetKind
|
||||
import com.m57.hermescontrol.data.model.ConnectionTargetState
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswerTarget
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorsOperationStatusParams
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -15,6 +20,9 @@ import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -109,19 +117,65 @@ class ChatConnectionOperationDelegateTest {
|
||||
)
|
||||
|
||||
assertEquals(1, requester.calls.size)
|
||||
val call = requester.calls.single()
|
||||
assertEquals(WsMethods.CONNECTION_RESPOND, call.first)
|
||||
assertEquals(setOf("owner", "op_id", "result"), call.second.keys)
|
||||
assertEquals(mapOf("type" to "session", "session_id" to "session-a"), call.second["owner"])
|
||||
assertEquals("op-a", call.second["op_id"])
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val result = call.second["result"] as Map<String, Any>
|
||||
val action = requester.calls.single()
|
||||
assertTrue(action is ConnectionOperationRequest.Respond)
|
||||
val respond = action as ConnectionOperationRequest.Respond
|
||||
|
||||
assertEquals(
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.session("session-a"),
|
||||
opId = "op-a",
|
||||
result =
|
||||
ConnectionAnswer(
|
||||
targets =
|
||||
listOf(
|
||||
ConnectionAnswerTarget(
|
||||
name = "github",
|
||||
status = "approved",
|
||||
env = mapOf("TOKEN" to canary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
respond.params,
|
||||
)
|
||||
|
||||
val encoded = OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), respond.params)
|
||||
val expectedWire =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "session-a")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-a")
|
||||
put(
|
||||
"result",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"targets",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("name", "github")
|
||||
put("status", "approved")
|
||||
put(
|
||||
"env",
|
||||
buildJsonObject {
|
||||
put("TOKEN", canary)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
assertEquals(expectedWire, encoded)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val answer = (result["targets"] as List<Map<String, Any>>).single()
|
||||
assertEquals("github", answer["name"])
|
||||
assertEquals("approved", answer["status"])
|
||||
assertEquals(mapOf("TOKEN" to canary), answer["env"])
|
||||
assertFalse(
|
||||
delegate.state.value
|
||||
.toString()
|
||||
@@ -166,12 +220,57 @@ class ChatConnectionOperationDelegateTest {
|
||||
skipDelegate.acceptRequest(snapshot())
|
||||
skipDelegate.respond("github", mapOf("TOKEN" to "must-drop"), approved = false)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val skipResult = skipRequester.calls.single().second["result"] as Map<String, Any>
|
||||
val skipAction = skipRequester.calls.single()
|
||||
assertTrue(skipAction is ConnectionOperationRequest.Respond)
|
||||
val skipRespond = skipAction as ConnectionOperationRequest.Respond
|
||||
assertEquals(
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.session("session-a"),
|
||||
opId = "op-a",
|
||||
result =
|
||||
ConnectionAnswer(
|
||||
targets =
|
||||
listOf(
|
||||
ConnectionAnswerTarget(
|
||||
name = "github",
|
||||
status = "skipped",
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
skipRespond.params,
|
||||
)
|
||||
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val skipAnswer = (skipResult["targets"] as List<Map<String, Any>>).single()
|
||||
assertEquals(mapOf("name" to "github", "status" to "skipped"), skipAnswer)
|
||||
val encodedSkip =
|
||||
OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), skipRespond.params)
|
||||
val expectedSkipWire =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "session-a")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-a")
|
||||
put(
|
||||
"result",
|
||||
buildJsonObject {
|
||||
put(
|
||||
"targets",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("name", "github")
|
||||
put("status", "skipped")
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
assertEquals(expectedSkipWire, encodedSkip)
|
||||
|
||||
val continueRequester = RecordingRequester()
|
||||
val continueDelegate = delegate(continueRequester)
|
||||
@@ -179,14 +278,38 @@ class ChatConnectionOperationDelegateTest {
|
||||
continueDelegate.acceptRequest(snapshot())
|
||||
continueDelegate.continueOperation()
|
||||
|
||||
val continueAction = continueRequester.calls.single()
|
||||
assertTrue(continueAction is ConnectionOperationRequest.Respond)
|
||||
val continueRespond = continueAction as ConnectionOperationRequest.Respond
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
|
||||
"op_id" to "op-a",
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.session("session-a"),
|
||||
opId = "op-a",
|
||||
result = ConnectionAnswer(settledBy = "continue"),
|
||||
),
|
||||
continueRequester.calls.single().second,
|
||||
continueRespond.params,
|
||||
)
|
||||
|
||||
val encodedContinue =
|
||||
OkHttpProvider.json.encodeToJsonElement(ConnectionRespondParams.serializer(), continueRespond.params)
|
||||
val expectedContinueWire =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "session-a")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-a")
|
||||
put(
|
||||
"result",
|
||||
buildJsonObject {
|
||||
put("settled_by", "continue")
|
||||
},
|
||||
)
|
||||
}
|
||||
assertEquals(expectedContinueWire, encodedContinue)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -199,15 +322,31 @@ class ChatConnectionOperationDelegateTest {
|
||||
|
||||
delegate.wake("op-a")
|
||||
|
||||
val call = requester.calls.single()
|
||||
assertEquals(WsMethods.CONNECTORS_OPERATION_WAKE, call.first)
|
||||
val action = requester.calls.single()
|
||||
assertTrue(action is ConnectionOperationRequest.Wake)
|
||||
val wake = action as ConnectionOperationRequest.Wake
|
||||
assertEquals(
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "session", "session_id" to "session-a"),
|
||||
"op_id" to "op-a",
|
||||
ConnectorsOperationStatusParams(
|
||||
owner = ConnectorOwner.session("session-a"),
|
||||
opId = "op-a",
|
||||
),
|
||||
call.second,
|
||||
wake.params,
|
||||
)
|
||||
|
||||
val encoded =
|
||||
OkHttpProvider.json.encodeToJsonElement(ConnectorsOperationStatusParams.serializer(), wake.params)
|
||||
val expectedWire =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"owner",
|
||||
buildJsonObject {
|
||||
put("type", "session")
|
||||
put("session_id", "session-a")
|
||||
},
|
||||
)
|
||||
put("op_id", "op-a")
|
||||
}
|
||||
assertEquals(expectedWire, encoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -265,7 +404,7 @@ class ChatConnectionOperationDelegateTest {
|
||||
runTest {
|
||||
val gate = CompletableDeferred<Unit>()
|
||||
val requester =
|
||||
ConnectionOperationRequester { _, _ ->
|
||||
ConnectionOperationRequester {
|
||||
gate.await()
|
||||
throw IllegalStateException("old failure")
|
||||
}
|
||||
@@ -355,13 +494,10 @@ class ChatConnectionOperationDelegateTest {
|
||||
private class RecordingRequester(
|
||||
private val failure: Throwable? = null,
|
||||
) : ConnectionOperationRequester {
|
||||
val calls = mutableListOf<Pair<String, Map<String, Any>>>()
|
||||
val calls = mutableListOf<ConnectionOperationRequest>()
|
||||
|
||||
override suspend fun request(
|
||||
method: String,
|
||||
params: Map<String, Any>,
|
||||
): Any? {
|
||||
calls += method to params
|
||||
override suspend fun request(action: ConnectionOperationRequest): Any? {
|
||||
calls += action
|
||||
failure?.let { throw it }
|
||||
return Unit
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.local.canonicalMessageOrder
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.isTimelineMarker
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/** Issue #1336: one owner for the REST row-id format and the known display kinds. */
|
||||
class ChatMessageIdentityTest {
|
||||
@Test
|
||||
fun restIdsRoundTripThroughTheSingleFormatOwner() {
|
||||
val id = RestMessageId.of("s-1", 42)
|
||||
|
||||
assertEquals("rest-s-1-42", id)
|
||||
assertTrue(RestMessageId.isRest(id))
|
||||
assertFalse(RestMessageId.isRest("ws-42"))
|
||||
assertEquals(42L, canonicalMessageOrder(id, "s-1"))
|
||||
assertNull(canonicalMessageOrder(RestMessageId.of("other", 42), "s-1"))
|
||||
assertEquals(id, ChatMessage(id = id, role = MessageRole.USER, content = "").canonicalRestId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun onlyUnknownOrMarkerKindsAreTimelineMarkers() {
|
||||
fun kind(value: String?) = ChatMessage(role = MessageRole.USER, content = "x", displayKind = value)
|
||||
|
||||
DisplayKind.nonMarkerKinds.forEach { assertFalse(it, kind(it).isTimelineMarker()) }
|
||||
assertTrue(kind(DisplayKind.MODEL_SWITCH).isTimelineMarker())
|
||||
assertTrue(kind("future_backend_kind").isTimelineMarker())
|
||||
assertFalse(kind(null).isTimelineMarker())
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import com.m57.hermescontrol.data.model.ModelProvider
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.remote.NetworkError
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -18,6 +18,8 @@ import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.JsonNull
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
@@ -31,8 +33,7 @@ class ChatModelSwitchDelegateTest {
|
||||
private val uiState =
|
||||
MutableStateFlow(ChatUiState(currentSessionId = "sess-1", currentSessionModel = "anthropic/claude-3"))
|
||||
private var runtimeId: String? = "runtime-1"
|
||||
private val sentMethods = mutableListOf<String>()
|
||||
private val sentParams = mutableListOf<Map<String, Any>>()
|
||||
private val sentParams = mutableListOf<ConfigSetParams>()
|
||||
private val slashCommands = mutableListOf<String>()
|
||||
private val assistantMessages = mutableListOf<String>()
|
||||
private var contextRefetched = 0
|
||||
@@ -71,10 +72,9 @@ class ChatModelSwitchDelegateTest {
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
runtimeSessionId = { runtimeId },
|
||||
wsSend = { method, params, onSent ->
|
||||
sentMethods.add(method)
|
||||
wsSend = { params, onSent ->
|
||||
sentParams.add(params)
|
||||
onSent?.invoke("req-${sentMethods.size}")
|
||||
onSent?.invoke("req-${sentParams.size}")
|
||||
},
|
||||
trackRequest = { _, _ -> },
|
||||
addAssistantMessage = { assistantMessages.add(it) },
|
||||
@@ -304,8 +304,9 @@ class ChatModelSwitchDelegateTest {
|
||||
delegate.handleModelSwitch("/MODEL gpt-4o --provider openai --session")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods)
|
||||
assertEquals("gpt-4o --provider openai --session", sentParams.first()["value"])
|
||||
assertEquals(1, sentParams.size)
|
||||
assertEquals("model", sentParams.first().key)
|
||||
assertEquals("gpt-4o --provider openai --session", sentParams.first().value)
|
||||
|
||||
delegate.handleConfigSetResult(
|
||||
id = "req-1",
|
||||
@@ -320,8 +321,8 @@ class ChatModelSwitchDelegateTest {
|
||||
|
||||
delegate.confirmModelSwitchExpensive()
|
||||
advanceUntilIdle()
|
||||
assertEquals(2, sentMethods.size)
|
||||
assertEquals(true, sentParams[1]["confirm_expensive_model"])
|
||||
assertEquals(2, sentParams.size)
|
||||
assertEquals(true, sentParams[1].confirmExpensiveModel)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -366,7 +367,6 @@ class ChatModelSwitchDelegateTest {
|
||||
// Switch to model with fast capability
|
||||
delegate.sendSlashModel("openai", "gpt-4o")
|
||||
advanceUntilIdle()
|
||||
sentMethods.clear()
|
||||
sentParams.clear()
|
||||
|
||||
assertTrue(uiState.value.currentModelCapabilities?.fast == true)
|
||||
@@ -377,10 +377,10 @@ class ChatModelSwitchDelegateTest {
|
||||
advanceUntilIdle()
|
||||
assertTrue(uiState.value.isFastModeChanging)
|
||||
assertFalse(uiState.value.fastMode) // Still unconfirmed!
|
||||
assertEquals(listOf(WsMethods.CONFIG_SET), sentMethods)
|
||||
assertEquals("fast", sentParams.first()["key"])
|
||||
assertEquals("fast", sentParams.first()["value"])
|
||||
assertEquals("runtime-1", sentParams.first()["session_id"])
|
||||
assertEquals(1, sentParams.size)
|
||||
assertEquals("fast", sentParams.first().key)
|
||||
assertEquals("fast", sentParams.first().value)
|
||||
assertEquals("runtime-1", sentParams.first().sessionId)
|
||||
|
||||
// Backend acknowledges
|
||||
delegate.handleConfigSetResult("req-1", mapOf("key" to "fast", "value" to "fast"))
|
||||
@@ -391,7 +391,7 @@ class ChatModelSwitchDelegateTest {
|
||||
delegate.toggleFastMode()
|
||||
advanceUntilIdle()
|
||||
assertTrue(uiState.value.isFastModeChanging)
|
||||
assertEquals("normal", sentParams.last()["value"])
|
||||
assertEquals("normal", sentParams.last().value)
|
||||
|
||||
delegate.handleConfigSetResult("req-2", mapOf("key" to "fast", "value" to "normal"))
|
||||
assertFalse(uiState.value.fastMode)
|
||||
@@ -405,7 +405,6 @@ class ChatModelSwitchDelegateTest {
|
||||
advanceUntilIdle()
|
||||
delegate.sendSlashModel("openai", "gpt-4o")
|
||||
advanceUntilIdle()
|
||||
sentMethods.clear()
|
||||
sentParams.clear()
|
||||
assertTrue(uiState.value.currentModelCapabilities?.fast == true)
|
||||
|
||||
@@ -420,10 +419,10 @@ class ChatModelSwitchDelegateTest {
|
||||
assertEquals(false, uiState.value.currentModelCapabilities?.fast)
|
||||
|
||||
// Further toggle attempts are no-ops
|
||||
val sentCount = sentMethods.size
|
||||
val sentCount = sentParams.size
|
||||
delegate.toggleFastMode()
|
||||
advanceUntilIdle()
|
||||
assertEquals(sentCount, sentMethods.size)
|
||||
assertEquals(sentCount, sentParams.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -470,9 +469,8 @@ class ChatModelSwitchDelegateTest {
|
||||
@Test
|
||||
fun setReasoningLevel_awaitsRequest_updatesOnAck_andPreventsConcurrentPick() =
|
||||
testScope.runTest {
|
||||
val gate = CompletableDeferred<Any?>()
|
||||
val reqMethods = mutableListOf<String>()
|
||||
val reqParams = mutableListOf<Map<String, Any>>()
|
||||
val gate = CompletableDeferred<JsonElement>()
|
||||
val reqParams = mutableListOf<ConfigSetParams>()
|
||||
|
||||
val customDelegate =
|
||||
ChatModelSwitchDelegate(
|
||||
@@ -480,14 +478,13 @@ class ChatModelSwitchDelegateTest {
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
runtimeSessionId = { runtimeId },
|
||||
wsSend = { _, _, _ -> },
|
||||
wsSend = { _, _ -> },
|
||||
trackRequest = { _, _ -> },
|
||||
addAssistantMessage = {},
|
||||
handleSlashCommand = {},
|
||||
fetchContextUsage = {},
|
||||
dataScopeFlow = dataScope,
|
||||
wsRequest = { method, params ->
|
||||
reqMethods.add(method)
|
||||
wsRequest = { params ->
|
||||
reqParams.add(params)
|
||||
gate.await()
|
||||
},
|
||||
@@ -503,14 +500,15 @@ class ChatModelSwitchDelegateTest {
|
||||
assertEquals("ultra", uiState.value.reasoningLevel)
|
||||
assertEquals("high", uiState.value.pendingReasoningLevel)
|
||||
assertNull(uiState.value.reasoningWireLevel)
|
||||
assertEquals(listOf(WsMethods.CONFIG_SET), reqMethods)
|
||||
assertEquals("session", reqParams.first()["scope"])
|
||||
assertEquals("high", reqParams.first()["value"])
|
||||
assertEquals(1, reqParams.size)
|
||||
assertEquals("reasoning", reqParams.first().key)
|
||||
assertEquals("session", reqParams.first().scope)
|
||||
assertEquals("high", reqParams.first().value)
|
||||
|
||||
// Second pick while in-flight is rejected (serialized)
|
||||
customDelegate.setReasoningLevel("low")
|
||||
runCurrent()
|
||||
assertEquals(1, reqMethods.size)
|
||||
assertEquals(1, reqParams.size)
|
||||
|
||||
// Simulate session.info winning the race and publishing the fresh
|
||||
// authoritative wire before the RPC ACK resumes the delegate.
|
||||
@@ -540,13 +538,13 @@ class ChatModelSwitchDelegateTest {
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
runtimeSessionId = { runtimeId },
|
||||
wsSend = { _, _, _ -> },
|
||||
wsSend = { _, _ -> },
|
||||
trackRequest = { _, _ -> },
|
||||
addAssistantMessage = {},
|
||||
handleSlashCommand = {},
|
||||
fetchContextUsage = {},
|
||||
dataScopeFlow = dataScope,
|
||||
wsRequest = { _, _ -> throw RuntimeException("4002 unknown reasoning value") },
|
||||
wsRequest = { throw RuntimeException("4002 unknown reasoning value") },
|
||||
)
|
||||
|
||||
customDelegate.setReasoningLevel("ultra")
|
||||
@@ -560,7 +558,7 @@ class ChatModelSwitchDelegateTest {
|
||||
@Test
|
||||
fun setReasoningLevel_whenRuntimeSessionNullOrBlank_doesNotSend() =
|
||||
testScope.runTest {
|
||||
val reqMethods = mutableListOf<String>()
|
||||
val reqParams = mutableListOf<ConfigSetParams>()
|
||||
runtimeId = null
|
||||
|
||||
val customDelegate =
|
||||
@@ -569,22 +567,22 @@ class ChatModelSwitchDelegateTest {
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
runtimeSessionId = { runtimeId },
|
||||
wsSend = { _, _, _ -> },
|
||||
wsSend = { _, _ -> },
|
||||
trackRequest = { _, _ -> },
|
||||
addAssistantMessage = {},
|
||||
handleSlashCommand = {},
|
||||
fetchContextUsage = {},
|
||||
dataScopeFlow = dataScope,
|
||||
wsRequest = { method, _ ->
|
||||
reqMethods.add(method)
|
||||
null
|
||||
wsRequest = { params ->
|
||||
reqParams.add(params)
|
||||
JsonNull
|
||||
},
|
||||
)
|
||||
|
||||
customDelegate.setReasoningLevel("high")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(reqMethods.isEmpty())
|
||||
assertTrue(reqParams.isEmpty())
|
||||
assertNull(uiState.value.pendingReasoningLevel)
|
||||
assertNull(uiState.value.reasoningLevel)
|
||||
}
|
||||
@@ -592,20 +590,20 @@ class ChatModelSwitchDelegateTest {
|
||||
@Test
|
||||
fun setReasoningLevel_staleScopeOrReset_dropsLateAck() =
|
||||
testScope.runTest {
|
||||
val gate = CompletableDeferred<Any?>()
|
||||
val gate = CompletableDeferred<JsonElement>()
|
||||
val customDelegate =
|
||||
ChatModelSwitchDelegate(
|
||||
scope = testScope,
|
||||
ioDispatcher = testDispatcher,
|
||||
uiState = uiState,
|
||||
runtimeSessionId = { runtimeId },
|
||||
wsSend = { _, _, _ -> },
|
||||
wsSend = { _, _ -> },
|
||||
trackRequest = { _, _ -> },
|
||||
addAssistantMessage = {},
|
||||
handleSlashCommand = {},
|
||||
fetchContextUsage = {},
|
||||
dataScopeFlow = dataScope,
|
||||
wsRequest = { _, _ -> gate.await() },
|
||||
wsRequest = { gate.await() },
|
||||
)
|
||||
|
||||
customDelegate.setReasoningLevel("high")
|
||||
@@ -617,7 +615,12 @@ class ChatModelSwitchDelegateTest {
|
||||
assertNull(uiState.value.pendingReasoningLevel)
|
||||
|
||||
// Late RPC response arrives
|
||||
gate.complete(mapOf("key" to "reasoning", "value" to "high"))
|
||||
gate.complete(
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
|
||||
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
|
||||
},
|
||||
)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertNull(uiState.value.reasoningLevel)
|
||||
|
||||
@@ -952,6 +952,100 @@ class ChatPagingMergeTest {
|
||||
)
|
||||
}
|
||||
|
||||
private fun laterTurnRows(assistantText: String) =
|
||||
listOf(
|
||||
SessionMessage(
|
||||
id = 0,
|
||||
role = "user",
|
||||
content = JsonPrimitive("long running task"),
|
||||
timestamp = JsonPrimitive(1),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 1,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive(assistantText),
|
||||
timestamp = JsonPrimitive(2),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 2,
|
||||
role = "user",
|
||||
content = JsonPrimitive("next question"),
|
||||
timestamp = JsonPrimitive(3),
|
||||
),
|
||||
SessionMessage(
|
||||
id = 3,
|
||||
role = "assistant",
|
||||
content = JsonPrimitive("next answer"),
|
||||
timestamp = JsonPrimitive(4),
|
||||
),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun stopNoticesStayInPlaceWhenInterruptedReplyIsStillLiveOnly() {
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
|
||||
val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = "Working on it...")
|
||||
val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
|
||||
val processes =
|
||||
ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "Stopped 2 background processes.")
|
||||
val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted")
|
||||
|
||||
val merged =
|
||||
applyServerPage(listOf(prompt, liveReply, stop, processes, interrupted), laterTurnRows("Working on it..."))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"rest-session-0",
|
||||
"rest-session-1",
|
||||
"uuid-stop",
|
||||
"uuid-procs",
|
||||
"uuid-int",
|
||||
"rest-session-2",
|
||||
"rest-session-3",
|
||||
),
|
||||
merged.map { it.canonicalRestId ?: it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun stopNoticesStayInPlaceWhenLongInterruptedReplyIsAPrefixOfTheServerCopy() {
|
||||
val long = "Working on the migration plan step by step, first the schema then the data"
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
|
||||
val liveReply = ChatMessage(id = "live-reply", role = MessageRole.ASSISTANT, content = long)
|
||||
val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
|
||||
val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted")
|
||||
|
||||
val merged = applyServerPage(listOf(prompt, liveReply, stop, interrupted), laterTurnRows("$long, then indexes"))
|
||||
|
||||
val ids = merged.map { it.canonicalRestId ?: it.id }
|
||||
assertTrue("notices before later turns: $ids", ids.indexOf("uuid-int") < ids.indexOf("rest-session-2"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun processResultArrivingAfterInterruptNoticeStaysBeforeLaterTurns() {
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "long running task")
|
||||
val reply = ChatMessage(id = "rest-session-1", role = MessageRole.ASSISTANT, content = "Working on it...")
|
||||
val stop = ChatMessage(id = "uuid-stop", role = MessageRole.USER, content = "/stop")
|
||||
val interrupted = ChatMessage(id = "uuid-int", role = MessageRole.SYSTEM, content = "Session interrupted")
|
||||
val processes =
|
||||
ChatMessage(id = "uuid-procs", role = MessageRole.SYSTEM, content = "No background processes to stop.")
|
||||
|
||||
val merged =
|
||||
applyServerPage(listOf(prompt, reply, stop, interrupted, processes), laterTurnRows("Working on it..."))
|
||||
|
||||
assertEquals(
|
||||
listOf(
|
||||
"rest-session-0",
|
||||
"rest-session-1",
|
||||
"uuid-stop",
|
||||
"uuid-int",
|
||||
"uuid-procs",
|
||||
"rest-session-2",
|
||||
"rest-session-3",
|
||||
),
|
||||
merged.map { it.canonicalRestId ?: it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun commandEchoAndOutputStayInPlaceAcrossFutureSyncs() {
|
||||
val prompt = ChatMessage(id = "rest-session-0", role = MessageRole.USER, content = "what model are you?")
|
||||
@@ -1115,4 +1209,59 @@ class ChatPagingMergeTest {
|
||||
chronological = !older,
|
||||
preserveLiveIds = true,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun restoredLocalCommandsKeepTheirChronologicalPlaceInCachedPage() {
|
||||
fun row(
|
||||
id: String,
|
||||
role: MessageRole,
|
||||
content: String,
|
||||
ts: Long,
|
||||
local: Long? = null,
|
||||
) = ChatMessage(id = id, role = role, content = content, timestamp = ts, localOrder = local)
|
||||
// Room order: confirmed rows first, then every local row (sort_group 1).
|
||||
val cachedPage =
|
||||
listOf(
|
||||
row("rest-s-1", MessageRole.USER, "hi", 10L),
|
||||
row("rest-s-2", MessageRole.ASSISTANT, "hello", 11L),
|
||||
row("rest-s-3", MessageRole.USER, "more", 30L),
|
||||
row("rest-s-4", MessageRole.ASSISTANT, "ok", 31L),
|
||||
row("rest-s-5", MessageRole.USER, "again", 50L),
|
||||
row("rest-s-6", MessageRole.ASSISTANT, "sure", 51L),
|
||||
row("cmd-a", MessageRole.USER, "/help", 12L, local = 1L),
|
||||
row("cmd-b", MessageRole.USER, "/usage", 32L, local = 2L),
|
||||
row("cmd-c", MessageRole.USER, "/model", 52L, local = 3L),
|
||||
)
|
||||
|
||||
val merged = mergeCachedTranscriptPage(cachedPage, emptyList())
|
||||
|
||||
assertEquals(
|
||||
listOf("rest-s-1", "rest-s-2", "cmd-a", "rest-s-3", "rest-s-4", "cmd-b", "rest-s-5", "rest-s-6", "cmd-c"),
|
||||
merged.map { it.id },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun restoredCommandOlderThanLoadedWindowStaysAboveItNotAtTheTail() {
|
||||
val server =
|
||||
listOf(
|
||||
ChatMessage(id = "rest-s-50", role = MessageRole.USER, content = "a", timestamp = 500L),
|
||||
ChatMessage(id = "rest-s-51", role = MessageRole.ASSISTANT, content = "b", timestamp = 501L),
|
||||
)
|
||||
val old =
|
||||
ChatMessage(
|
||||
id = "cmd",
|
||||
role = MessageRole.USER,
|
||||
content = "/model x",
|
||||
timestamp = 100L,
|
||||
localOrder = 9L,
|
||||
)
|
||||
val merged = mergeCachedTranscriptPage(server + old, emptyList())
|
||||
assertEquals(listOf("cmd", "rest-s-50", "rest-s-51"), merged.map { it.id })
|
||||
// Stable when an older page arrives afterwards.
|
||||
val older = ChatMessage(id = "rest-s-10", role = MessageRole.USER, content = "o", timestamp = 50L)
|
||||
val olderMid = ChatMessage(id = "rest-s-11", role = MessageRole.ASSISTANT, content = "p", timestamp = 150L)
|
||||
val more = mergeCachedTranscriptPage(listOf(older, olderMid), merged)
|
||||
assertEquals(listOf("rest-s-10", "cmd", "rest-s-11", "rest-s-50", "rest-s-51"), more.map { it.id })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
/**
|
||||
* Issue #1335: prefix dedupe must not merge distinct messages.
|
||||
*/
|
||||
class ChatPrefixDedupeTest {
|
||||
// ── Contract 1 ────────────────────────────────────────────────────────────
|
||||
// Two USER messages, both >=40 chars, one a strict prefix of the other,
|
||||
// different ids -> NOT the same logical message (both survive a merge).
|
||||
|
||||
@Test
|
||||
fun contract1_userMessages_strictPrefixDistinctIds_notSameLogicalMessage() {
|
||||
val prefixText = "Please write a comprehensive test suite for issue thirteen thirty five."
|
||||
val fullText = "$prefixText It should cover all edge cases thoroughly."
|
||||
val msg1 = ChatMessage(id = "user-msg-1", role = MessageRole.USER, content = prefixText)
|
||||
val msg2 = ChatMessage(id = "user-msg-2", role = MessageRole.USER, content = fullText)
|
||||
|
||||
assertFalse(sameLogicalMessage(msg1, msg2))
|
||||
assertFalse(sameLogicalMessage(msg2, msg1))
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(msg2), listOf(msg1), preserveLiveIds = true)
|
||||
assertEquals(2, merged.size)
|
||||
assertTrue(merged.any { it.id == "user-msg-1" && it.content == prefixText })
|
||||
assertTrue(merged.any { it.id == "user-msg-2" && it.content == fullText })
|
||||
}
|
||||
|
||||
// ── Contract 2 ────────────────────────────────────────────────────────────
|
||||
// Two ASSISTANT messages that are both canonical REST rows (distinct restIds /
|
||||
// canonical ids), one a >=40-char prefix of the other -> NOT same.
|
||||
|
||||
@Test
|
||||
fun contract2_assistantCanonicalRestRows_strictPrefix_notSameLogicalMessage() {
|
||||
val prefixText = "I have inspected the project repository and verified the build files."
|
||||
val fullText = "$prefixText Now proceeding with the implementation of the requested tests."
|
||||
val restRow1 =
|
||||
ChatMessage(
|
||||
id = "rest-s-101",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = prefixText,
|
||||
restId = "rest-s-101",
|
||||
)
|
||||
val restRow2 =
|
||||
ChatMessage(
|
||||
id = "rest-s-102",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = fullText,
|
||||
restId = "rest-s-102",
|
||||
)
|
||||
|
||||
assertFalse(sameLogicalMessage(restRow1, restRow2))
|
||||
assertFalse(sameLogicalMessage(restRow2, restRow1))
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(restRow2), listOf(restRow1), preserveLiveIds = true)
|
||||
assertEquals(2, merged.size)
|
||||
}
|
||||
|
||||
// ── Contract 3 ────────────────────────────────────────────────────────────
|
||||
// TOOL rows never match by content prefix.
|
||||
|
||||
@Test
|
||||
fun contract3_toolRows_neverMatchByContentPrefix() {
|
||||
val prefixToolContent = """{"output":"build succeeded without any compiler errors or warnings during check"}"""
|
||||
val fullToolContent =
|
||||
"""{"output":"build succeeded without any compiler errors or warnings during check and all tests pass"}"""
|
||||
val tool1 =
|
||||
ChatMessage(
|
||||
id = "tool-1",
|
||||
role = MessageRole.TOOL,
|
||||
content = prefixToolContent,
|
||||
toolName = "terminal",
|
||||
toolCallId = "call-1",
|
||||
)
|
||||
val tool2 =
|
||||
ChatMessage(
|
||||
id = "tool-2",
|
||||
role = MessageRole.TOOL,
|
||||
content = fullToolContent,
|
||||
toolName = "terminal",
|
||||
toolCallId = "call-2",
|
||||
)
|
||||
|
||||
assertFalse(sameLogicalMessage(tool1, tool2))
|
||||
assertFalse(sameLogicalMessage(tool2, tool1))
|
||||
}
|
||||
|
||||
// ── Contract 4 ────────────────────────────────────────────────────────────
|
||||
// Exact-equal trimmed content still matches as before for a live vs REST pair
|
||||
// of the same role (sanity).
|
||||
|
||||
@Test
|
||||
fun contract4_exactEqualTrimmedContent_liveVsRest_matchesSameLogicalMessage() {
|
||||
for (role in listOf(MessageRole.USER, MessageRole.ASSISTANT)) {
|
||||
val live =
|
||||
ChatMessage(
|
||||
id = "live-temp-id",
|
||||
role = role,
|
||||
content = "\n\nHello, this is an important message for testing.\n ",
|
||||
)
|
||||
val rest =
|
||||
ChatMessage(
|
||||
id = "rest-s-200",
|
||||
role = role,
|
||||
content = "Hello, this is an important message for testing.",
|
||||
restId = "rest-s-200",
|
||||
)
|
||||
|
||||
assertTrue("Role $role exact-equal trimmed content must match", sameLogicalMessage(live, rest))
|
||||
assertTrue(
|
||||
"Role $role exact-equal trimmed content must match symmetrically",
|
||||
sameLogicalMessage(rest, live),
|
||||
)
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(rest), listOf(live), preserveLiveIds = true)
|
||||
assertEquals("Exact-equal trimmed pair should deduplicate into single message", 1, merged.size)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Contract 5 ────────────────────────────────────────────────────────────
|
||||
// Issue #842 scenario still dedupes: a live sealed interim assistant narration
|
||||
// without completion/REST identity whose text (after trim) is a prefix of /
|
||||
// equal-prefix to the persisted REST narration -> treated as same (reuse the
|
||||
// shape used in ChatHistoryCommentaryTest if it exists).
|
||||
|
||||
@Test
|
||||
fun contract5_issue842_liveSealedInterimAssistantNarration_prefixOfRestNarration_stillDedupes() {
|
||||
// Issue #842 / ChatToolDedupeTest pattern:
|
||||
// Live sealed interim bubble without REST identity raced flush and ends prematurely.
|
||||
// Rest row contains the full/completed narration.
|
||||
val liveOrphan =
|
||||
ChatMessage(
|
||||
id = "live-orphan-sealed",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content =
|
||||
"\n\ntool's loaded! 🔍 now searchin' for the **best hummus recipe**" +
|
||||
" — gimme dat creamy dreamy chickpea",
|
||||
)
|
||||
val restRow =
|
||||
ChatMessage(
|
||||
id = "rest-s-842",
|
||||
role = MessageRole.ASSISTANT,
|
||||
content =
|
||||
"tool's loaded! 🔍 now searchin' for the **best hummus recipe**" +
|
||||
" — gimme dat creamy dreamy chickpea goodness:",
|
||||
restId = "rest-s-842",
|
||||
)
|
||||
|
||||
assertTrue(sameLogicalMessage(liveOrphan, restRow))
|
||||
assertTrue(sameLogicalMessage(restRow, liveOrphan))
|
||||
|
||||
val merged = mergeTranscriptWithLive(listOf(restRow), listOf(liveOrphan), preserveLiveIds = true)
|
||||
assertEquals(
|
||||
"Live interim orphan should deduplicate against persisted REST narration",
|
||||
1,
|
||||
merged.size,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -34,15 +34,25 @@ class ChatSearchControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excludes tool rows even when the payload contains the query`() {
|
||||
fun `finds visible tool name without indexing hidden raw payload`() {
|
||||
val messages =
|
||||
listOf(
|
||||
message(MessageRole.USER, "check the logs"),
|
||||
message(MessageRole.TOOL, "{\"result\": \"check the logs: all green\"}"),
|
||||
message(MessageRole.ASSISTANT, "all good"),
|
||||
message(MessageRole.TOOL, "unrendered private payload").copy(toolName = "terminal"),
|
||||
)
|
||||
|
||||
assertEquals(listOf(SearchMatch(0, 0)), hits(controller.findMatches(messages, "check the logs")))
|
||||
assertEquals(listOf(SearchMatch(1, 0, SearchTarget.TOOL)), hits(controller.findMatches(messages, "terminal")))
|
||||
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(messages, "private payload")))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `system marker carried as user does not yield an invisible hit`() {
|
||||
val marker =
|
||||
message(
|
||||
MessageRole.USER,
|
||||
"You've reached the maximum number of tool-calling iterations allowed.",
|
||||
)
|
||||
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(listOf(marker), "maximum")))
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -57,7 +67,7 @@ class ChatSearchControllerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excludes reasoning text that never made it to visible content`() {
|
||||
fun `finds reasoning text independently of assistant prose`() {
|
||||
val messages =
|
||||
listOf(
|
||||
message(
|
||||
@@ -67,8 +77,10 @@ class ChatSearchControllerTest {
|
||||
),
|
||||
)
|
||||
|
||||
// Only the visible content is searchable — reasoning is invisible.
|
||||
assertEquals(emptyList<SearchMatch>(), hits(controller.findMatches(messages, "deployment")))
|
||||
assertEquals(
|
||||
listOf(SearchMatch(0, 19, SearchTarget.REASONING)),
|
||||
hits(controller.findMatches(messages, "deployment")),
|
||||
)
|
||||
assertEquals(listOf(SearchMatch(0, 12)), hits(controller.findMatches(messages, "answer")))
|
||||
}
|
||||
|
||||
|
||||
@@ -139,6 +139,39 @@ class ChatSearchDelegateTest {
|
||||
assertEquals(0, delegate.searchState.currentIndex)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `search tracks target kind while navigating reasoning tool and prose`() =
|
||||
runTest {
|
||||
val uiState =
|
||||
MutableStateFlow(
|
||||
ChatUiState(
|
||||
messages =
|
||||
listOf(
|
||||
ChatMessage("m0", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
|
||||
ChatMessage("m1", MessageRole.TOOL, "opaque", toolName = "thinking_tool"),
|
||||
),
|
||||
),
|
||||
)
|
||||
val delegate =
|
||||
ChatSearchDelegate(
|
||||
scope = backgroundScope,
|
||||
uiState = uiState,
|
||||
dispatcher = StandardTestDispatcher(testScheduler),
|
||||
debounceMs = 150,
|
||||
)
|
||||
|
||||
delegate.setSearchQuery("thinking")
|
||||
advanceTimeBy(150)
|
||||
runCurrent()
|
||||
assertEquals(listOf(SearchTarget.REASONING, SearchTarget.TOOL), delegate.searchState.matchTargets)
|
||||
assertEquals("m0", delegate.searchState.currentMatchId)
|
||||
delegate.navigateSearchMatch(1)
|
||||
assertEquals("m1", delegate.searchState.currentMatchId)
|
||||
assertEquals(SearchTarget.TOOL, delegate.searchState.matchTargets[delegate.searchState.currentIndex])
|
||||
delegate.clearSearch()
|
||||
assertEquals(emptyList<SearchTarget>(), delegate.searchState.matchTargets)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clearSearch resets everything including matched sets`() =
|
||||
runTest {
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.MessageReaction
|
||||
import com.m57.hermescontrol.data.model.SessionMessage
|
||||
import com.m57.hermescontrol.notification.ReplyNotificationTarget
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
/** Issue #1337: the REST mapper runs on plain inputs, with no Android, AuthManager, or tracker state. */
|
||||
class ChatServerMessageMapperPurityTest {
|
||||
private fun assistant(
|
||||
id: Int,
|
||||
text: String,
|
||||
) = SessionMessage(id = id, role = "assistant", content = JsonPrimitive(text))
|
||||
|
||||
@Test
|
||||
fun mediaAttachmentsUseTheInjectedUrlBuilder() {
|
||||
val mapped =
|
||||
mapServerMessages(
|
||||
"s",
|
||||
listOf(assistant(1, "Chart MEDIA:/opt/hermes/chart.png")),
|
||||
0,
|
||||
true,
|
||||
emptyList(),
|
||||
mediaUrl = { path -> "gw://$path" },
|
||||
).single()
|
||||
|
||||
assertEquals("Chart", mapped.content.trim())
|
||||
assertEquals(listOf("gw:///opt/hermes/chart.png"), mapped.attachments?.map { it.uri })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaWithoutAGatewayStripsTheTokenAndAddsNoAttachment() {
|
||||
val mapped =
|
||||
mapServerMessages("s", listOf(assistant(1, "Chart MEDIA:/opt/hermes/chart.png")), 0, true, emptyList())
|
||||
.single()
|
||||
|
||||
assertEquals("Chart", mapped.content.trim())
|
||||
assertNull(mapped.attachments)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun activeReplyTargetIsAPlainInputScopedToItsSession() {
|
||||
val rows = listOf(assistant(100, "Done"), assistant(200, "Done"))
|
||||
val target = ReplyNotificationTarget("default", "s", "comp", 1L, serverMessageId = 100)
|
||||
|
||||
val bound = mapServerMessages("s", rows, 0, true, emptyList(), activeReplyTarget = target)
|
||||
val otherSession =
|
||||
mapServerMessages("s", rows, 0, true, emptyList(), activeReplyTarget = target.copy(sessionId = "x"))
|
||||
|
||||
assertEquals(listOf("comp", null), bound.map { it.completionId })
|
||||
assertEquals(listOf(null, null), otherSession.map { it.completionId })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rowsWithoutTimestampsUseTheInjectedClock() {
|
||||
val mapped = mapServerMessages("s", listOf(assistant(1, "Hi")), 0, true, emptyList(), nowMs = 42L)
|
||||
|
||||
assertEquals(42L, mapped.single().timestamp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun persistedReactionsHydrateFromDisplayMetadata() {
|
||||
val row =
|
||||
SessionMessage(
|
||||
id = 5,
|
||||
role = "user",
|
||||
content = JsonPrimitive("hello"),
|
||||
display_metadata =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"reactions",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("emoji", "\uD83D\uDE02")
|
||||
put("author", "agent")
|
||||
},
|
||||
)
|
||||
add(buildJsonObject { put("author", "user") })
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
val mapped = mapServerMessages("s", listOf(row), 0, true, emptyList()).single()
|
||||
assertEquals(listOf(MessageReaction("\uD83D\uDE02", "agent")), mapped.reactions)
|
||||
assertEquals(
|
||||
emptyList<MessageReaction>(),
|
||||
mapServerMessages("s", listOf(assistant(6, "x")), 0, true, emptyList()).single().reactions,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@ import org.junit.Test
|
||||
|
||||
class ChatStreamingControllerTest {
|
||||
@Test
|
||||
fun flushPendingReasoning_flushesThrottledReasoningOnTransition() =
|
||||
fun flushPendingTransition_flushesThrottledReasoning() =
|
||||
runTest {
|
||||
// isTestEnvironment = false so the ~33ms throttle is live. The FIRST delta
|
||||
// always flushes (lastFlushMs == 0), but a SECOND delta within 33ms is held
|
||||
// in the buffer and only pushed out by flushPendingReasoning() (called at
|
||||
// in the buffer and only pushed out by flushPendingTransition() (called at
|
||||
// streaming transitions) — that is the exact drop we guard against.
|
||||
val uiState = MutableStateFlow(ChatUiState())
|
||||
val streamingState =
|
||||
@@ -38,13 +38,13 @@ class ChatStreamingControllerTest {
|
||||
assertEquals("A", streamingState.value.streamingMessage?.reasoningText)
|
||||
|
||||
// A streaming transition forces the buffered reasoning onto the message.
|
||||
controller.flushPendingReasoning()
|
||||
controller.flushPendingTransition()
|
||||
|
||||
assertEquals("AB", streamingState.value.streamingMessage?.reasoningText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flushPendingTokens_flushesThrottledTokensBeforeSeal() =
|
||||
fun flushPendingTransition_flushesThrottledTokensBeforeSeal() =
|
||||
runTest {
|
||||
// Issue #842: a delta landing <33ms before tool.start stays in the
|
||||
// throttled buffer; flushing the tokens before the reducer seals the
|
||||
@@ -73,7 +73,7 @@ class ChatStreamingControllerTest {
|
||||
|
||||
// The tool.start transition forces the buffered tokens out so the
|
||||
// sealed orphan carries the complete narration.
|
||||
controller.flushPendingTokens()
|
||||
controller.flushPendingTransition()
|
||||
|
||||
assertEquals(
|
||||
"tool's loaded! 🔍 now searchin' for the best hummus",
|
||||
@@ -95,7 +95,7 @@ class ChatStreamingControllerTest {
|
||||
// A fresh reasoning delta + flush must carry ONLY the new value, proving the
|
||||
// stale buffer was cleared and cannot be resurrected.
|
||||
controller.handleReasoningDelta(WsEvent.ReasoningDelta("Fresh reasoning", "session"))
|
||||
controller.flushPendingReasoning()
|
||||
controller.flushPendingTransition()
|
||||
|
||||
assertEquals("Fresh reasoning", streamingState.value.reasoningText)
|
||||
}
|
||||
@@ -168,6 +168,24 @@ class ChatStreamingControllerTest {
|
||||
assertEquals("First Second", streamingState.value.streamingMessage?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun thinkingDelta_trailingFlushAndResetDoNotLeakIntoNextTurn() =
|
||||
runTest {
|
||||
val uiState = MutableStateFlow(ChatUiState())
|
||||
val streamingState = MutableStateFlow(StreamingState())
|
||||
val controller = controller(this, uiState, streamingState, isTestEnvironment = { false })
|
||||
|
||||
controller.handleThinkingDelta(WsEvent.ThinkingDelta("Think", "session"))
|
||||
controller.handleThinkingDelta(WsEvent.ThinkingDelta(" more", "session"))
|
||||
assertEquals("Think", streamingState.value.thinkingText)
|
||||
testScheduler.advanceTimeBy(35L)
|
||||
assertEquals("Think more", streamingState.value.thinkingText)
|
||||
|
||||
controller.resetStreaming()
|
||||
controller.handleThinkingDelta(WsEvent.ThinkingDelta("Fresh", "session"))
|
||||
assertEquals("Fresh", streamingState.value.thinkingText)
|
||||
}
|
||||
|
||||
private fun controller(
|
||||
scope: CoroutineScope,
|
||||
uiState: MutableStateFlow<ChatUiState>,
|
||||
|
||||
@@ -448,7 +448,7 @@ class ChatSubagentsDelegateTest {
|
||||
),
|
||||
)
|
||||
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returnsMany
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returnsMany
|
||||
listOf(
|
||||
SubagentTailResponse(subagentId = "sub-1", text = "Line 1\n", truncated = false),
|
||||
SubagentTailResponse(subagentId = "sub-1", text = "Line 1\nLine 2\n", truncated = false),
|
||||
@@ -485,7 +485,7 @@ class ChatSubagentsDelegateTest {
|
||||
),
|
||||
)
|
||||
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns
|
||||
SubagentTailResponse(subagentId = "sub-1", text = "Finished output", truncated = false)
|
||||
|
||||
delegate.inspectSubagentTranscript("sub-1")
|
||||
@@ -507,7 +507,7 @@ class ChatSubagentsDelegateTest {
|
||||
|
||||
advanceTimeBy(3000)
|
||||
// Verify tailSubagent was called at most once or twice, not indefinitely
|
||||
coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1", 16384) }
|
||||
coVerify(atMost = 2) { mockRepository.tailSubagent("session-123", "sub-1") }
|
||||
delegate.closeSubagentTranscript()
|
||||
}
|
||||
|
||||
@@ -526,7 +526,7 @@ class ChatSubagentsDelegateTest {
|
||||
),
|
||||
)
|
||||
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns
|
||||
SubagentTailResponse(subagentId = "sub-1", text = "Output")
|
||||
|
||||
delegate.inspectSubagentTranscript("sub-1")
|
||||
@@ -553,7 +553,7 @@ class ChatSubagentsDelegateTest {
|
||||
),
|
||||
)
|
||||
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } throws
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } throws
|
||||
RuntimeException("Gateway connection error")
|
||||
|
||||
delegate.inspectSubagentTranscript("sub-1")
|
||||
@@ -565,7 +565,7 @@ class ChatSubagentsDelegateTest {
|
||||
assertFalse(transcript?.isLoading == true)
|
||||
|
||||
// Now recover on retry
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1", 16384) } returns
|
||||
coEvery { mockRepository.tailSubagent("session-123", "sub-1") } returns
|
||||
SubagentTailResponse(subagentId = "sub-1", text = "Recovered logs")
|
||||
|
||||
delegate.retryTranscript()
|
||||
|
||||
@@ -35,6 +35,10 @@ import com.m57.hermescontrol.data.ws.JsonRpcError
|
||||
import com.m57.hermescontrol.data.ws.ModelCatalogStore
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ProcessStopParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ProcessStopResult
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.notification.TurnCorrelationTracker
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||
@@ -63,6 +67,7 @@ import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
@@ -280,6 +285,9 @@ class ChatViewModelTest {
|
||||
HermesWsClient.send(arg(0), arg(1)) {}
|
||||
CompletableDeferred<Any?>(Unit)
|
||||
}
|
||||
// /stop also fires process.stop; default to "nothing to kill" so unrelated /stop tests never hit the socket.
|
||||
coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns
|
||||
ProcessStopResult(killed = 0)
|
||||
mockkObject(ApiClient)
|
||||
mockkObject(HermesDatabase)
|
||||
|
||||
@@ -767,8 +775,8 @@ class ChatViewModelTest {
|
||||
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent)
|
||||
assertEquals(sessionId, branchSent!!.second["session_id"])
|
||||
assertEquals("my-fork", branchSent.second["name"])
|
||||
assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"])
|
||||
assertEquals(JsonPrimitive("my-fork"), branchSent.second["name"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -813,7 +821,7 @@ class ChatViewModelTest {
|
||||
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be dispatched for /fork", branchSent)
|
||||
assertEquals(sessionId, branchSent!!.second["session_id"])
|
||||
assertEquals(JsonPrimitive(sessionId), branchSent!!.second["session_id"])
|
||||
assertFalse("name param should be omitted when no title given", branchSent.second.containsKey("name"))
|
||||
}
|
||||
|
||||
@@ -876,8 +884,8 @@ class ChatViewModelTest {
|
||||
|
||||
val branchWholeSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH_WHOLE }
|
||||
assertNotNull("session.branch_whole should be sent", branchWholeSent)
|
||||
assertEquals("my-retry-branch", branchWholeSent!!.second["name"])
|
||||
assertEquals(sessionId, branchWholeSent.second["session_id"])
|
||||
assertEquals(JsonPrimitive("my-retry-branch"), branchWholeSent!!.second["name"])
|
||||
assertEquals(JsonPrimitive(sessionId), branchWholeSent.second["session_id"])
|
||||
|
||||
// Backend answers -32601 unknown method
|
||||
mockEventsFlow.emit(
|
||||
@@ -891,8 +899,8 @@ class ChatViewModelTest {
|
||||
// Retries ONCE with session.branch using same params
|
||||
val branchSent = captured.firstOrNull { it.first == WsMethods.SESSION_BRANCH }
|
||||
assertNotNull("session.branch fallback should be sent", branchSent)
|
||||
assertEquals("my-retry-branch", branchSent!!.second["name"])
|
||||
assertEquals(sessionId, branchSent.second["session_id"])
|
||||
assertEquals(JsonPrimitive("my-retry-branch"), branchSent!!.second["name"])
|
||||
assertEquals(JsonPrimitive(sessionId), branchSent.second["session_id"])
|
||||
assertEquals(1, captured.count { it.first == WsMethods.SESSION_BRANCH })
|
||||
|
||||
// session.branch result is then handled
|
||||
@@ -963,6 +971,52 @@ class ChatViewModelTest {
|
||||
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_stop_alsoKillsBackgroundProcesses() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } returns
|
||||
ProcessStopResult(killed = 2)
|
||||
|
||||
viewModel.sendMessage("/stop")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||
coVerify(exactly = 1) { HermesWsClient.call(RpcMethods.PROCESS_STOP, ProcessStopParams, any(), any()) }
|
||||
val contents =
|
||||
viewModel.uiState.value.messages
|
||||
.map { it.content }
|
||||
assertTrue(contents.contains("Stopped 2 background processes."))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_interrupt_doesNotKillBackgroundProcesses() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
viewModel.sendMessage("/interrupt")
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||
coVerify(exactly = 0) { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_stop_reportsProcessStopFailure() =
|
||||
runTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
coEvery { HermesWsClient.call(RpcMethods.PROCESS_STOP, any(), any(), any()) } throws
|
||||
IllegalStateException("boom")
|
||||
|
||||
viewModel.sendMessage("/stop")
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(
|
||||
viewModel.uiState.value.messages
|
||||
.any { it.content == "Could not stop background processes: boom" },
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSlashCommand_stop_andInterruptedNoticeStayInPlaceAcrossSync() =
|
||||
runTest {
|
||||
@@ -1038,7 +1092,15 @@ class ChatViewModelTest {
|
||||
|
||||
val contents = merged.map { it.content }
|
||||
assertEquals(
|
||||
listOf("start task", "running...", "/stop", "Session interrupted", "next prompt", "next reply"),
|
||||
listOf(
|
||||
"start task",
|
||||
"running...",
|
||||
"/stop",
|
||||
"No background processes to stop.",
|
||||
"Session interrupted",
|
||||
"next prompt",
|
||||
"next reply",
|
||||
),
|
||||
contents,
|
||||
)
|
||||
}
|
||||
@@ -1077,7 +1139,12 @@ class ChatViewModelTest {
|
||||
verify {
|
||||
HermesWsClient.request(
|
||||
WsMethods.PROMPT_BTW,
|
||||
mapOf("session_id" to sessionId, "text" to "which file was that in?"),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"session_id" to JsonPrimitive(sessionId),
|
||||
"text" to JsonPrimitive("which file was that in?"),
|
||||
),
|
||||
),
|
||||
any(),
|
||||
)
|
||||
}
|
||||
@@ -1289,15 +1356,9 @@ class ChatViewModelTest {
|
||||
// commands). NOT command.dispatch (4018s on /model), NOT prompt.submit
|
||||
// (LLM would treat it as text). Capture the config.set params.
|
||||
val modelCalls = mutableListOf<Triple<String, String, String>>()
|
||||
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
|
||||
val params = arg<Map<String, Any>>(1)
|
||||
modelCalls.add(
|
||||
Triple(
|
||||
params["key"] as String,
|
||||
params["value"] as String,
|
||||
params["session_id"] as String,
|
||||
),
|
||||
)
|
||||
every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
|
||||
val params = arg<ConfigSetParams>(1)
|
||||
modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty()))
|
||||
"req-cfg-${modelCalls.size}"
|
||||
}
|
||||
|
||||
@@ -1309,7 +1370,7 @@ class ChatViewModelTest {
|
||||
"openai/gpt-4o",
|
||||
viewModel.uiState.value.currentSessionModel,
|
||||
)
|
||||
verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) }
|
||||
verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) }
|
||||
val call = modelCalls.firstOrNull { it.first == "model" }
|
||||
assertNotNull("selection must route through config.set key=model", call)
|
||||
assertEquals("gpt-4o --provider openai --session", call!!.second)
|
||||
@@ -1334,7 +1395,7 @@ class ChatViewModelTest {
|
||||
// (key="model"), which the gateway routes to _apply_model_switch. NOT
|
||||
// command.dispatch (4018s on /model) and NOT prompt.submit (LLM would
|
||||
// treat it as text).
|
||||
verify { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) }
|
||||
verify { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1347,15 +1408,9 @@ class ChatViewModelTest {
|
||||
// slash prefix must be stripped, or parse_model_flags on the
|
||||
// backend won't recognize it and the hot-swap silently fails.
|
||||
val modelCalls = mutableListOf<Triple<String, String, String>>()
|
||||
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
|
||||
val params = arg<Map<String, Any>>(1)
|
||||
modelCalls.add(
|
||||
Triple(
|
||||
params["key"] as String,
|
||||
params["value"] as String,
|
||||
params["session_id"] as String,
|
||||
),
|
||||
)
|
||||
every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
|
||||
val params = arg<ConfigSetParams>(1)
|
||||
modelCalls.add(Triple(params.key, params.value, params.sessionId.orEmpty()))
|
||||
"req-cfg-ci-${modelCalls.size}"
|
||||
}
|
||||
|
||||
@@ -1382,9 +1437,9 @@ class ChatViewModelTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
|
||||
var lastReqId = ""
|
||||
val capturedParams = mutableListOf<Map<String, Any>>()
|
||||
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
|
||||
val params = arg<Map<String, Any>>(1)
|
||||
val capturedParams = mutableListOf<ConfigSetParams>()
|
||||
every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
|
||||
val params = arg<ConfigSetParams>(1)
|
||||
capturedParams.add(params)
|
||||
val id = "req-confirm-${capturedParams.size}"
|
||||
lastReqId = id
|
||||
@@ -1397,7 +1452,7 @@ class ChatViewModelTest {
|
||||
advanceUntilIdle()
|
||||
|
||||
assertEquals(1, capturedParams.size)
|
||||
assertNull(capturedParams[0]["confirm_expensive_model"])
|
||||
assertNull(capturedParams[0].confirmExpensiveModel)
|
||||
|
||||
// Backend returns confirm_required = true
|
||||
mockEventsFlow.emit(
|
||||
@@ -1423,12 +1478,12 @@ class ChatViewModelTest {
|
||||
|
||||
assertNull(viewModel.uiState.value.modelSwitchConfirmMessage)
|
||||
assertEquals(2, capturedParams.size)
|
||||
assertEquals(true, capturedParams[1]["confirm_expensive_model"])
|
||||
assertEquals(true, capturedParams[1].confirmExpensiveModel)
|
||||
assertEquals(
|
||||
"muse-spark-1.3-contributor-free --provider opencode-free --session",
|
||||
capturedParams[1]["value"],
|
||||
capturedParams[1].value,
|
||||
)
|
||||
assertEquals(sessionId, capturedParams[1]["session_id"])
|
||||
assertEquals(sessionId, capturedParams[1].sessionId)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -1437,7 +1492,7 @@ class ChatViewModelTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
|
||||
var lastReqId = ""
|
||||
every { HermesWsClient.send(WsMethods.CONFIG_SET, any(), any()) } answers {
|
||||
every { HermesWsClient.send(RpcMethods.CONFIG_SET, any<ConfigSetParams>(), any()) } answers {
|
||||
val id = "req-cfg-dismiss"
|
||||
lastReqId = id
|
||||
arg<((String) -> Unit)?>(2)?.invoke(id)
|
||||
@@ -1487,7 +1542,11 @@ class ChatViewModelTest {
|
||||
every {
|
||||
HermesWsClient.request(
|
||||
WsMethods.COMMAND_DISPATCH,
|
||||
mapOf("name" to "undo", "arg" to "2", "session_id" to sessionId),
|
||||
buildJsonObject {
|
||||
put("name", "undo")
|
||||
put("arg", "2")
|
||||
put("session_id", sessionId)
|
||||
},
|
||||
any(),
|
||||
)
|
||||
} returns
|
||||
@@ -1573,7 +1632,12 @@ class ChatViewModelTest {
|
||||
verify {
|
||||
HermesWsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to "session-from-notification", "omit_messages" to true),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"session_id" to JsonPrimitive("session-from-notification"),
|
||||
"omit_messages" to JsonPrimitive(true),
|
||||
),
|
||||
),
|
||||
any(),
|
||||
)
|
||||
}
|
||||
@@ -3858,6 +3922,41 @@ class ChatViewModelTest {
|
||||
verify(exactly = 1) { HermesWsClient.sendMessage(sessionId, "Fresh turn", any(), false) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lateCompletionAfterInterruptUpdatesSealedReplyInPlace() =
|
||||
runTest {
|
||||
val (viewModel, sessionId) = createViewModelWithSession()
|
||||
mockEventsFlow.emit(WsEvent.MessageStart(sessionId))
|
||||
mockEventsFlow.emit(WsEvent.MessageToken("partial reply", sessionId))
|
||||
advanceUntilIdle()
|
||||
val originalId =
|
||||
viewModel.streamingState.value.streamingMessage!!
|
||||
.id
|
||||
|
||||
viewModel.interruptSession()
|
||||
advanceUntilIdle()
|
||||
val id = sentRequestMethods.last { it.first == WsMethods.SESSION_INTERRUPT }.second
|
||||
mockEventsFlow.emit(WsEvent.RpcResult(id, mapOf("status" to "interrupted")))
|
||||
advanceUntilIdle()
|
||||
assertNull(viewModel.streamingState.value.streamingMessage)
|
||||
|
||||
mockEventsFlow.emit(WsEvent.MessageToken("late tail", sessionId))
|
||||
advanceUntilIdle()
|
||||
assertNull(viewModel.streamingState.value.streamingMessage)
|
||||
mockEventsFlow.emit(WsEvent.MessageComplete("partial reply with final tail", sessionId))
|
||||
advanceUntilIdle()
|
||||
|
||||
val messages = viewModel.uiState.value.messages
|
||||
val replies = messages.filter { it.role == MessageRole.ASSISTANT }
|
||||
assertEquals(1, replies.size)
|
||||
assertEquals(originalId, replies.single().id)
|
||||
assertEquals("partial reply with final tail", replies.single().content)
|
||||
assertTrue(
|
||||
messages.indexOf(replies.single()) < messages.indexOfFirst { it.content == "Session interrupted" },
|
||||
)
|
||||
assertNull(viewModel.streamingState.value.interruptedMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hardInterruptWaitsForAckBeforeSubmitting() =
|
||||
runTest {
|
||||
@@ -4983,7 +5082,12 @@ class ChatViewModelTest {
|
||||
verify {
|
||||
HermesWsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to "session-456", "omit_messages" to true),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"session_id" to JsonPrimitive("session-456"),
|
||||
"omit_messages" to JsonPrimitive(true),
|
||||
),
|
||||
),
|
||||
any(),
|
||||
)
|
||||
}
|
||||
@@ -4996,7 +5100,7 @@ class ChatViewModelTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val resumeRequests = mutableMapOf<String, String>()
|
||||
every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers {
|
||||
val sessionId = arg<Map<String, String>>(1).getValue("session_id")
|
||||
val sessionId = (arg<Map<String, Any?>>(1).getValue("session_id") as JsonPrimitive).content
|
||||
val requestId = "resume-$sessionId"
|
||||
resumeRequests[sessionId] = requestId
|
||||
arg<((String) -> Unit)?>(2)?.invoke(requestId)
|
||||
@@ -5090,7 +5194,7 @@ class ChatViewModelTest {
|
||||
val (viewModel, _) = createViewModelWithSession()
|
||||
val resumeRequests = mutableMapOf<String, String>()
|
||||
every { HermesWsClient.send(WsMethods.SESSION_RESUME, any(), any()) } answers {
|
||||
val sessionId = arg<Map<String, String>>(1).getValue("session_id")
|
||||
val sessionId = (arg<Map<String, Any?>>(1).getValue("session_id") as JsonPrimitive).content
|
||||
val requestId = "resume-$sessionId"
|
||||
resumeRequests[sessionId] = requestId
|
||||
arg<((String) -> Unit)?>(2)?.invoke(requestId)
|
||||
@@ -5689,14 +5793,14 @@ class ChatViewModelTest {
|
||||
verify(exactly = 0) {
|
||||
HermesWsClient.request(
|
||||
WsMethods.SESSION_CONTEXT_BREAKDOWN,
|
||||
match { it["session_id"] == "session-456" },
|
||||
match { it["session_id"] == JsonPrimitive("session-456") },
|
||||
any(),
|
||||
)
|
||||
}
|
||||
verify(exactly = 0) {
|
||||
HermesWsClient.request(
|
||||
WsMethods.SESSION_USAGE,
|
||||
match { it["session_id"] == "session-456" },
|
||||
match { it["session_id"] == JsonPrimitive("session-456") },
|
||||
any(),
|
||||
)
|
||||
}
|
||||
@@ -5718,18 +5822,18 @@ class ChatViewModelTest {
|
||||
verify {
|
||||
HermesWsClient.request(
|
||||
WsMethods.SESSION_CONTEXT_BREAKDOWN,
|
||||
match { it["session_id"] == "runtime-456" },
|
||||
match { it["session_id"] == JsonPrimitive("runtime-456") },
|
||||
any(),
|
||||
)
|
||||
}
|
||||
verify {
|
||||
HermesWsClient.request(
|
||||
WsMethods.SESSION_USAGE,
|
||||
match { it["session_id"] == "runtime-456" },
|
||||
match { it["session_id"] == JsonPrimitive("runtime-456") },
|
||||
any(),
|
||||
)
|
||||
}
|
||||
assertEquals("runtime-456", paramsSlot.captured["session_id"])
|
||||
assertEquals(JsonPrimitive("runtime-456"), paramsSlot.captured["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -5833,7 +5937,12 @@ class ChatViewModelTest {
|
||||
verify {
|
||||
HermesWsClient.send(
|
||||
WsMethods.SESSION_RESUME,
|
||||
mapOf("session_id" to "session-456", "omit_messages" to true),
|
||||
JsonObject(
|
||||
mapOf(
|
||||
"session_id" to JsonPrimitive("session-456"),
|
||||
"omit_messages" to JsonPrimitive(true),
|
||||
),
|
||||
),
|
||||
any(),
|
||||
)
|
||||
}
|
||||
@@ -6343,7 +6452,13 @@ class ChatViewModelTest {
|
||||
viewModel.interruptSession()
|
||||
advanceUntilIdle()
|
||||
|
||||
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, mapOf("session_id" to sessionId), any()) }
|
||||
verify {
|
||||
HermesWsClient.send(
|
||||
WsMethods.SESSION_INTERRUPT,
|
||||
match { it["session_id"] == JsonPrimitive(sessionId) && it.size == 1 },
|
||||
any(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -6592,9 +6707,9 @@ class ChatViewModelTest {
|
||||
HermesWsClient.send(
|
||||
WsMethods.APPROVAL_RESPOND,
|
||||
withArg { params ->
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("once", params["choice"])
|
||||
assertEquals("req-42", params["request_id"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
assertEquals(JsonPrimitive("once"), params["choice"])
|
||||
assertEquals(JsonPrimitive("req-42"), params["request_id"])
|
||||
},
|
||||
any(),
|
||||
)
|
||||
@@ -6626,8 +6741,8 @@ class ChatViewModelTest {
|
||||
HermesWsClient.send(
|
||||
WsMethods.APPROVAL_RESPOND,
|
||||
withArg { params ->
|
||||
assertEquals("session", params["choice"])
|
||||
assertEquals("req-7", params["request_id"])
|
||||
assertEquals(JsonPrimitive("session"), params["choice"])
|
||||
assertEquals(JsonPrimitive("req-7"), params["request_id"])
|
||||
},
|
||||
any(),
|
||||
)
|
||||
@@ -6657,7 +6772,7 @@ class ChatViewModelTest {
|
||||
HermesWsClient.send(
|
||||
WsMethods.APPROVAL_RESPOND,
|
||||
withArg { params ->
|
||||
assertEquals("deny", params["choice"])
|
||||
assertEquals(JsonPrimitive("deny"), params["choice"])
|
||||
assertFalse(params.containsKey("request_id"))
|
||||
},
|
||||
any(),
|
||||
@@ -6690,7 +6805,7 @@ class ChatViewModelTest {
|
||||
HermesWsClient.send(
|
||||
WsMethods.APPROVAL_PENDING,
|
||||
withArg { params ->
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
},
|
||||
any(),
|
||||
)
|
||||
@@ -6736,8 +6851,8 @@ class ChatViewModelTest {
|
||||
HermesWsClient.send(
|
||||
WsMethods.APPROVAL_RECEIVED,
|
||||
withArg { params ->
|
||||
assertEquals("runtime-sid-999", params["session_id"])
|
||||
assertEquals("req-runtime-test", params["request_id"])
|
||||
assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"])
|
||||
assertEquals(JsonPrimitive("req-runtime-test"), params["request_id"])
|
||||
},
|
||||
any(),
|
||||
)
|
||||
@@ -6750,16 +6865,16 @@ class ChatViewModelTest {
|
||||
HermesWsClient.send(
|
||||
WsMethods.APPROVAL_RESPOND,
|
||||
withArg { params ->
|
||||
assertEquals("runtime-sid-999", params["session_id"])
|
||||
assertEquals("once", params["choice"])
|
||||
assertEquals("req-runtime-test", params["request_id"])
|
||||
assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"])
|
||||
assertEquals(JsonPrimitive("once"), params["choice"])
|
||||
assertEquals(JsonPrimitive("req-runtime-test"), params["request_id"])
|
||||
},
|
||||
any(),
|
||||
)
|
||||
HermesWsClient.send(
|
||||
WsMethods.APPROVAL_PENDING,
|
||||
withArg { params ->
|
||||
assertEquals("runtime-sid-999", params["session_id"])
|
||||
assertEquals(JsonPrimitive("runtime-sid-999"), params["session_id"])
|
||||
},
|
||||
any(),
|
||||
)
|
||||
@@ -7221,7 +7336,7 @@ class ChatViewModelTest {
|
||||
verify { HermesWsClient.request(WsMethods.IMAGE_ATTACH_BYTES, any()) }
|
||||
assertEquals(
|
||||
"session_id must be forwarded to image.attach_bytes",
|
||||
sessionId,
|
||||
JsonPrimitive(sessionId),
|
||||
paramsSlot.captured["session_id"],
|
||||
)
|
||||
assertTrue("encoded attachment cache must be cleaned", attachmentCacheDir.listFiles().isNullOrEmpty())
|
||||
@@ -9470,7 +9585,7 @@ class ChatViewModelTest {
|
||||
any(),
|
||||
)
|
||||
}
|
||||
assertEquals("session-stored-999", resumeParamsSlot.captured["session_id"])
|
||||
assertEquals(JsonPrimitive("session-stored-999"), resumeParamsSlot.captured["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.MessageReaction
|
||||
import com.m57.hermescontrol.data.model.UsageSnapshotResponse
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import org.junit.Assert.assertEquals
|
||||
@@ -9,6 +10,43 @@ import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatWsEventReducerTest {
|
||||
@Test
|
||||
fun interruptedIdentitySurvivesDoneButNotANewStart() {
|
||||
val partial = ChatMessage(id = "partial", role = MessageRole.ASSISTANT, content = "old reply")
|
||||
val state = ChatUiState(currentSessionId = "session-1", messages = listOf(partial))
|
||||
val interrupted = StreamingState(interruptedMessage = partial)
|
||||
val done =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
interrupted,
|
||||
WsEvent.MessageDone("session-1"),
|
||||
"session-1",
|
||||
)
|
||||
assertEquals(partial, done.streamingState.interruptedMessage)
|
||||
val started =
|
||||
ChatWsEventReducer.reduce(
|
||||
done.state,
|
||||
done.streamingState,
|
||||
WsEvent.MessageStart("session-1"),
|
||||
"session-1",
|
||||
)
|
||||
assertNull(started.streamingState.interruptedMessage)
|
||||
val complete =
|
||||
ChatWsEventReducer.reduce(
|
||||
started.state,
|
||||
started.streamingState,
|
||||
WsEvent.MessageComplete("new reply", "session-1"),
|
||||
"session-1",
|
||||
)
|
||||
assertEquals(
|
||||
"old reply",
|
||||
complete.state.messages
|
||||
.first()
|
||||
.content,
|
||||
)
|
||||
assertEquals(2, complete.state.messages.count { it.role == MessageRole.ASSISTANT })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMessageComplete_clearsResolvedClarifyRequest() {
|
||||
val state =
|
||||
@@ -1605,4 +1643,60 @@ class ChatWsEventReducerTest {
|
||||
assertEquals("idle", result.state.compressionStatus)
|
||||
assertEquals(initialMessages, result.state.messages)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageReactionPaintsOnlyTheRowWithTheMatchingServerId() {
|
||||
val target = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L)
|
||||
val other = ChatMessage(id = "b", role = MessageRole.ASSISTANT, content = "yo", serverRowId = 8L)
|
||||
val state = ChatUiState(currentSessionId = "s", messages = listOf(target, other))
|
||||
val reactions = listOf(MessageReaction("\u2764\uFE0F", "agent"))
|
||||
val result =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
StreamingState(),
|
||||
WsEvent.MessageReactionUpdated(7L, reactions, "user", "s"),
|
||||
"s",
|
||||
)
|
||||
assertEquals(reactions, result.state.messages[0].reactions)
|
||||
assertTrue(
|
||||
result.state.messages[1]
|
||||
.reactions
|
||||
.isEmpty(),
|
||||
)
|
||||
val retracted =
|
||||
ChatWsEventReducer.reduce(
|
||||
result.state,
|
||||
result.streamingState,
|
||||
WsEvent.MessageReactionUpdated(7L, emptyList(), "user", "s"),
|
||||
"s",
|
||||
)
|
||||
assertTrue(
|
||||
retracted.state.messages[0]
|
||||
.reactions
|
||||
.isEmpty(),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun messageReactionForUnknownRowOrOtherSessionIsIgnored() {
|
||||
val msg = ChatMessage(id = "a", role = MessageRole.USER, content = "hi", serverRowId = 7L)
|
||||
val state = ChatUiState(currentSessionId = "s", messages = listOf(msg))
|
||||
val reactions = listOf(MessageReaction("\uD83D\uDC4D", "agent"))
|
||||
val unknownRow =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
StreamingState(),
|
||||
WsEvent.MessageReactionUpdated(99L, reactions, "user", "s"),
|
||||
"s",
|
||||
)
|
||||
assertEquals(state, unknownRow.state)
|
||||
val otherSession =
|
||||
ChatWsEventReducer.reduce(
|
||||
state,
|
||||
StreamingState(),
|
||||
WsEvent.MessageReactionUpdated(7L, reactions, "user", "zzz"),
|
||||
"s",
|
||||
)
|
||||
assertEquals(state, otherSession.state)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import com.m57.hermescontrol.theme.GlyphWhite
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import com.m57.hermescontrol.theme.resolveColorScheme
|
||||
import org.junit.Assert.assertTrue
|
||||
@@ -66,6 +67,20 @@ class ComposerPaletteTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun actionButtonFollowsThePresetPrimaryWhenItIsLegible() {
|
||||
forEveryPresetMode { mode, p, _ ->
|
||||
val scheme = resolveColorScheme(ThemePreset.valueOf(mode.substringBefore(" ")), mode.endsWith("true"))
|
||||
if (contrast(scheme.primary, p.card) >= 3f) {
|
||||
assertTrue("$mode action must be primary", p.action == scheme.primary)
|
||||
assertTrue(
|
||||
"$mode glyph must be white or onPrimary",
|
||||
p.onAction == scheme.onPrimary || p.onAction == GlyphWhite,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun controlsAndCardEdgeStayVisibleInEveryPresetMode() {
|
||||
forEveryPresetMode { mode, p, background ->
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.chat
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.fullBleedItemKeys
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.messageIdToLazyIndex
|
||||
import com.m57.hermescontrol.ui.chat.fullbleed.searchMatchToLazyIndex
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
@@ -24,6 +25,37 @@ class FullBleedItemKeysTest {
|
||||
assertEquals(mapOf("u" to 2, "a" to 5), messageIdToLazyIndex(turns, leadingItems = 2))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchTargetsResolveHoistedReasoningAndToolRows() {
|
||||
val messages =
|
||||
listOf(
|
||||
ChatMessage("u", MessageRole.USER, "question"),
|
||||
ChatMessage("t", MessageRole.TOOL, "output", toolName = "terminal"),
|
||||
ChatMessage("a", MessageRole.ASSISTANT, "answer", reasoningText = "thinking"),
|
||||
)
|
||||
val turns = groupIntoTurns(messages)
|
||||
|
||||
assertEquals(1, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0, SearchTarget.REASONING)))
|
||||
assertEquals(2, searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.TOOL)))
|
||||
assertEquals(3, searchMatchToLazyIndex(turns, messages, SearchMatch(2, 0)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun secondaryReasoningResolvesToItsProseRow() {
|
||||
val messages =
|
||||
listOf(
|
||||
ChatMessage("a1", MessageRole.ASSISTANT, "one", reasoningText = "first"),
|
||||
ChatMessage("a2", MessageRole.ASSISTANT, "two", reasoningText = "second"),
|
||||
)
|
||||
val turns = groupIntoTurns(messages)
|
||||
val keys = fullBleedItemKeys(turns)
|
||||
|
||||
assertEquals(
|
||||
keys.indexOf("prose-a2"),
|
||||
searchMatchToLazyIndex(turns, messages, SearchMatch(1, 0, SearchTarget.REASONING)),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun toolAnchorSurvivesPrependThatExtendsItsAgentTurn() {
|
||||
val tool = ChatMessage("live-tool", MessageRole.TOOL, "output")
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.Assert.assertArrayEquals
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import java.util.Base64
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ImageBytesResolverTest {
|
||||
private val context = mockk<Context>(relaxed = true)
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
// resolve() runs on Dispatchers.IO; bind the main dispatcher for tests.
|
||||
Dispatchers.setMain(StandardTestDispatcher())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extensionForMime maps known types`() {
|
||||
assertEquals("png", ImageBytesResolver.extensionForMime("image/png"))
|
||||
assertEquals("png", ImageBytesResolver.extensionForMime(" image/png; charset=binary"))
|
||||
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/jpeg"))
|
||||
assertEquals("jpg", ImageBytesResolver.extensionForMime("image/JPG"))
|
||||
assertEquals("gif", ImageBytesResolver.extensionForMime("image/gif"))
|
||||
assertEquals("webp", ImageBytesResolver.extensionForMime("image/webp"))
|
||||
assertEquals("heic", ImageBytesResolver.extensionForMime("image/heic"))
|
||||
assertEquals("svg", ImageBytesResolver.extensionForMime("image/svg+xml"))
|
||||
assertEquals("img", ImageBytesResolver.extensionForMime("image/unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data URL base64 decodes to bytes with mime and extension`() =
|
||||
runTest {
|
||||
val raw = "hello-image".toByteArray()
|
||||
val b64 = Base64.getEncoder().encodeToString(raw)
|
||||
val model = "data:image/png;base64,$b64"
|
||||
|
||||
val result = ImageBytesResolver.resolve(context, model, "image/*")
|
||||
|
||||
assertTrue(result is ImageBytesResolver.Result.Bytes)
|
||||
result as ImageBytesResolver.Result.Bytes
|
||||
assertArrayEquals(raw, result.bytes)
|
||||
assertEquals("image/png", result.mimeType)
|
||||
assertEquals("png", result.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `data URL falls back to fallbackMime when meta omitted`() =
|
||||
runTest {
|
||||
val raw = byteArrayOf(1, 2, 3, 4)
|
||||
val b64 = Base64.getEncoder().encodeToString(raw)
|
||||
val model = "data:;base64,$b64"
|
||||
|
||||
val result = ImageBytesResolver.resolve(context, model, "image/webp")
|
||||
|
||||
assertTrue(result is ImageBytesResolver.Result.Bytes)
|
||||
result as ImageBytesResolver.Result.Bytes
|
||||
assertEquals("image/webp", result.mimeType)
|
||||
assertEquals("webp", result.extension)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `malformed data URL (no comma) returns Error`() =
|
||||
runTest {
|
||||
val result = ImageBytesResolver.resolve(context, "data:image/png;base64XXXX", "image/*")
|
||||
assertTrue(result is ImageBytesResolver.Result.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-base64 data URL returns Error`() =
|
||||
runTest {
|
||||
val result = ImageBytesResolver.resolve(context, "data:image/png,notbase64", "image/*")
|
||||
assertTrue(result is ImageBytesResolver.Result.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unsupported model source returns Error`() =
|
||||
runTest {
|
||||
val result = ImageBytesResolver.resolve(context, "file:///sdcard/x.png", "image/*")
|
||||
assertTrue(result is ImageBytesResolver.Result.Error)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.StatusBlue
|
||||
import com.m57.hermescontrol.theme.StatusBlueContainer
|
||||
import com.m57.hermescontrol.theme.StatusGreen
|
||||
import com.m57.hermescontrol.theme.StatusGreenContainer
|
||||
import com.m57.hermescontrol.theme.StatusRed
|
||||
import com.m57.hermescontrol.theme.StatusRedContainer
|
||||
import com.m57.hermescontrol.theme.StatusYellow
|
||||
import com.m57.hermescontrol.theme.StatusYellowContainer
|
||||
import com.m57.hermescontrol.theme.searchHighlightColors
|
||||
import com.m57.hermescontrol.ui.chat.markdown.BulletRun
|
||||
import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText
|
||||
import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns
|
||||
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||
import org.junit.Test
|
||||
import kotlin.system.measureNanoTime
|
||||
|
||||
/** Diagnostic only (opt-in via -PincludeBenchmarks): per-update cost while a list streams from 1 to 1000 bullets. */
|
||||
class MarkdownBulletRunBenchmark {
|
||||
private val highlights =
|
||||
searchHighlightColors(
|
||||
HermesStatusColors(
|
||||
success = StatusGreen,
|
||||
successContainer = StatusGreenContainer,
|
||||
onSuccess = Color.White,
|
||||
warning = StatusYellow,
|
||||
warningContainer = StatusYellowContainer,
|
||||
onWarning = Color.White,
|
||||
error = StatusRed,
|
||||
errorContainer = StatusRedContainer,
|
||||
onError = Color.White,
|
||||
info = StatusBlue,
|
||||
infoContainer = StatusBlueContainer,
|
||||
onInfo = Color.White,
|
||||
),
|
||||
)
|
||||
|
||||
private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md))
|
||||
|
||||
private fun bullets(n: Int) = (1..n).joinToString("\n") { "- item $it" }
|
||||
|
||||
private fun render(run: BulletRun): AnnotatedString =
|
||||
buildBulletRunText(run, Density(1f), Color.Black, "", false, Color.Blue, highlights) { (it.length * 7).sp }
|
||||
|
||||
@Test
|
||||
fun benchmarkStreamingGrowth() {
|
||||
val sizes = listOf(100, 250, 500, 750, 1000)
|
||||
val sb = StringBuilder()
|
||||
var done = 0
|
||||
val results = mutableListOf<String>()
|
||||
// warm the JIT
|
||||
repeat(30) { coalesce(bullets(1000)) }
|
||||
for (target in sizes) {
|
||||
while (done < target) {
|
||||
sb.append("- item ${++done}\n")
|
||||
}
|
||||
val md = sb.toString()
|
||||
val n = 50
|
||||
val parse = measureNanoTime { repeat(n) { parseBlocks(md) } } / n / 1e6
|
||||
val full =
|
||||
measureNanoTime {
|
||||
repeat(n) {
|
||||
(coalesce(md).firstOrNull { it is BulletRun } as? BulletRun)?.let { render(it) }
|
||||
}
|
||||
} / n / 1e6
|
||||
results += "$target bullets: parse=${"%.2f".format(parse)}ms parse+coalesce+build=${"%.2f".format(full)}ms"
|
||||
}
|
||||
results.forEach { println("BENCHMARK_RESULT: stream $it") }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.LinkAnnotation
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import androidx.compose.ui.unit.sp
|
||||
import com.m57.hermescontrol.theme.HermesStatusColors
|
||||
import com.m57.hermescontrol.theme.StatusBlue
|
||||
import com.m57.hermescontrol.theme.StatusBlueContainer
|
||||
import com.m57.hermescontrol.theme.StatusGreen
|
||||
import com.m57.hermescontrol.theme.StatusGreenContainer
|
||||
import com.m57.hermescontrol.theme.StatusRed
|
||||
import com.m57.hermescontrol.theme.StatusRedContainer
|
||||
import com.m57.hermescontrol.theme.StatusYellow
|
||||
import com.m57.hermescontrol.theme.StatusYellowContainer
|
||||
import com.m57.hermescontrol.theme.searchHighlightColors
|
||||
import com.m57.hermescontrol.ui.chat.markdown.BulletRun
|
||||
import com.m57.hermescontrol.ui.chat.markdown.MIN_BULLET_RUN
|
||||
import com.m57.hermescontrol.ui.chat.markdown.MdBlock
|
||||
import com.m57.hermescontrol.ui.chat.markdown.buildBulletRunText
|
||||
import com.m57.hermescontrol.ui.chat.markdown.coalesceBulletRuns
|
||||
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
private val HIGHLIGHTS =
|
||||
searchHighlightColors(
|
||||
HermesStatusColors(
|
||||
success = StatusGreen,
|
||||
successContainer = StatusGreenContainer,
|
||||
onSuccess = Color.White,
|
||||
warning = StatusYellow,
|
||||
warningContainer = StatusYellowContainer,
|
||||
onWarning = Color.White,
|
||||
error = StatusRed,
|
||||
errorContainer = StatusRedContainer,
|
||||
onError = Color.White,
|
||||
info = StatusBlue,
|
||||
infoContainer = StatusBlueContainer,
|
||||
onInfo = Color.White,
|
||||
),
|
||||
)
|
||||
|
||||
private fun bullets(
|
||||
n: Int,
|
||||
text: (Int) -> String = { "item $it" },
|
||||
) = (1..n).joinToString("\n") { "- ${text(it)}" }
|
||||
|
||||
private fun coalesce(md: String) = coalesceBulletRuns(parseBlocks(md))
|
||||
|
||||
/** Stand-in for TextMeasurer: prefix width grows with font size, like the real bullet glyph. */
|
||||
private fun fakePrefixWidth(fontSizeSp: Float): (String) -> TextUnit =
|
||||
{ prefix -> (prefix.length * fontSizeSp * 0.5f).sp }
|
||||
|
||||
private fun render(
|
||||
run: BulletRun,
|
||||
query: String = "",
|
||||
density: Density = Density(1f),
|
||||
fontSizeSp: Float = 14f,
|
||||
) = buildBulletRunText(run, density, Color.Black, query, false, Color.Blue, HIGHLIGHTS, fakePrefixWidth(fontSizeSp))
|
||||
|
||||
class MarkdownBulletRunTest {
|
||||
@Test
|
||||
fun longPlainRunCoalescesIntoOneBlock() {
|
||||
val out = coalesce(bullets(1000))
|
||||
assertEquals(1, out.size)
|
||||
assertEquals(1000, (out.single() as BulletRun).items.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun shortRunStaysUncoalesced() {
|
||||
val out = coalesce(bullets(MIN_BULLET_RUN - 1))
|
||||
assertEquals(MIN_BULLET_RUN - 1, out.size)
|
||||
assertTrue(out.all { it is MdBlock.Bullet })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun runAtThresholdCoalesces() {
|
||||
assertTrue(coalesce(bullets(MIN_BULLET_RUN)).single() is BulletRun)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rtlBulletBreaksTheRunAndKeepsOldRenderer() {
|
||||
val md = bullets(40) { if (it == 20) "مرحبا بالعالم" else "item $it" }
|
||||
val out = coalesce(md)
|
||||
assertEquals(3, out.size)
|
||||
assertEquals(19, (out[0] as BulletRun).items.size)
|
||||
assertTrue(out[1] is MdBlock.Bullet)
|
||||
assertEquals(20, (out[2] as BulletRun).items.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun fullyRtlListNeverCoalesces() {
|
||||
val out = coalesce(bullets(40) { "مرحبا $it" })
|
||||
assertTrue(out.none { it is BulletRun })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun inlineMathBreaksTheRun() {
|
||||
val out = coalesce(bullets(40) { if (it == 20) "energy \$E=mc^2\$" else "item $it" })
|
||||
assertTrue(out[1] is MdBlock.Bullet)
|
||||
assertEquals(3, out.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nestedBulletsStayInTheRunWithLevelGlyphAndIndent() {
|
||||
val md = bullets(20) + "\n - child\n - grandchild\n" + bullets(20)
|
||||
val run = coalesce(md).single() as BulletRun
|
||||
assertEquals(42, run.items.size)
|
||||
assertEquals(
|
||||
listOf(0, 1, 2, 0),
|
||||
listOf(run.items[19], run.items[20], run.items[21], run.items[22]).map { it.level },
|
||||
)
|
||||
val text = render(run)
|
||||
val lines = text.text.split("\n")
|
||||
assertTrue(lines[20].startsWith("\u25E6"))
|
||||
assertTrue(lines[21].startsWith("\u25AA"))
|
||||
val indents =
|
||||
text.paragraphStyles.map {
|
||||
it.item.textIndent!!
|
||||
.firstLine.value
|
||||
}
|
||||
assertEquals(listOf(0f, 16f, 32f, 0f), listOf(indents[19], indents[20], indents[21], indents[22]))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bulletWithNestedSourceFallsBack() {
|
||||
val md = bullets(20) + "\n- parent\n ```\n code\n ```\n" + bullets(20)
|
||||
val out = coalesce(md)
|
||||
assertTrue(out.any { it is MdBlock.Bullet && it.nestedSource.isNotEmpty() })
|
||||
assertTrue(out.filterIsInstance<BulletRun>().flatMap { it.items }.all { it.nestedSource.isEmpty() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun taskAndOrderedItemsNeverJoinARun() {
|
||||
val md = bullets(20) + "\n- [ ] todo\n1. one\n2. two\n" + bullets(20)
|
||||
val out = coalesce(md)
|
||||
assertEquals(2, out.count { it is BulletRun })
|
||||
assertTrue(out.any { it is MdBlock.Task })
|
||||
assertTrue(out.any { it is MdBlock.Ordered })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun surroundingBlocksAreUntouchedAndOrdered() {
|
||||
val out = coalesce("# Title\n\n" + bullets(30) + "\n\nTail paragraph")
|
||||
assertTrue(out.first() is MdBlock.Heading)
|
||||
assertTrue(out[1] is BulletRun)
|
||||
assertTrue(out.last() is MdBlock.Paragraph)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mergedTextKeepsEveryLineWithGlyphs() {
|
||||
val run = coalesce(bullets(20)).single() as BulletRun
|
||||
val lines = render(run).text.split("\n")
|
||||
assertEquals(20, lines.size)
|
||||
assertTrue(lines.all { it.startsWith("\u2022") })
|
||||
assertTrue(lines.last().endsWith("item 20"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun linksSurviveMerging() {
|
||||
val run =
|
||||
coalesce(bullets(20) { if (it == 5) "see [docs](https://example.com/a) now" else "item $it" })
|
||||
.single() as BulletRun
|
||||
val text = render(run)
|
||||
val links = text.getLinkAnnotations(0, text.length)
|
||||
assertEquals(1, links.size)
|
||||
assertEquals("https://example.com/a", (links.single().item as LinkAnnotation.Url).url)
|
||||
assertEquals("docs", text.text.substring(links.single().start, links.single().end))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun bareUrlsStayLinks() {
|
||||
val run = coalesce(bullets(20) { if (it == 3) "https://example.com/x" else "item $it" }).single() as BulletRun
|
||||
val text = render(run)
|
||||
assertEquals(1, text.getLinkAnnotations(0, text.length).size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchHighlightLandsOnTheMatchOffsets() {
|
||||
val run = coalesce(bullets(20)).single() as BulletRun
|
||||
val text = render(run, query = "item 7")
|
||||
val spans = text.spanStyles.filter { it.item.background != Color.Unspecified }
|
||||
assertTrue(spans.isNotEmpty())
|
||||
spans.forEach { assertEquals("item 7", text.text.substring(it.start, it.end)) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noHighlightWithoutQuery() {
|
||||
val run = coalesce(bullets(20)).single() as BulletRun
|
||||
assertTrue(render(run).spanStyles.none { it.item.background != Color.Unspecified })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun levelOffsetIsPhysicalDpAcrossFontScales() {
|
||||
val run = coalesce(bullets(20) + "\n - child\n").single() as BulletRun
|
||||
val normal = render(run, density = Density(2f, fontScale = 1f))
|
||||
val big = render(run, density = Density(2f, fontScale = 2f))
|
||||
val n =
|
||||
normal.paragraphStyles[20]
|
||||
.item.textIndent!!
|
||||
.firstLine.value
|
||||
val b =
|
||||
big.paragraphStyles[20]
|
||||
.item.textIndent!!
|
||||
.firstLine.value
|
||||
// 16dp at density 2 = 32px; sp value shrinks by fontScale so physical size stays 16dp.
|
||||
assertEquals(n, b * 2f, 0.001f)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun hangingIndentIncludesTheFontRelativePrefix() {
|
||||
val run = coalesce(bullets(20)).single() as BulletRun
|
||||
val small =
|
||||
render(run, fontSizeSp = 14f)
|
||||
.paragraphStyles
|
||||
.first()
|
||||
.item.textIndent!!
|
||||
val large =
|
||||
render(run, fontSizeSp = 28f)
|
||||
.paragraphStyles
|
||||
.first()
|
||||
.item.textIndent!!
|
||||
// Wrapped lines must sit right of the first line by the prefix width, which grows with the font.
|
||||
assertTrue(small.restLine.value > small.firstLine.value)
|
||||
assertEquals(
|
||||
(small.restLine.value - small.firstLine.value) * 2f,
|
||||
large.restLine.value - large.firstLine.value,
|
||||
0.001f,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun oneParagraphStylePerBullet() {
|
||||
val run = coalesce(bullets(50)).single() as BulletRun
|
||||
assertEquals(50, render(run).paragraphStyles.size)
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.markdown.parseBlocks
|
||||
import org.junit.Test
|
||||
import kotlin.system.measureNanoTime
|
||||
import kotlin.system.measureTimeMillis
|
||||
|
||||
class MarkdownTextBenchmark {
|
||||
@@ -47,4 +48,19 @@ class MarkdownTextBenchmark {
|
||||
|
||||
println("BENCHMARK_RESULT: parseBlocks 10000 iterations took ${time}ms")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun benchmarkParseBlocksThousandBullets() {
|
||||
val md = (1..1000).joinToString("\n") { "- $it" }
|
||||
|
||||
// Cold: first parse in this JVM, before any warmup.
|
||||
val cold = measureNanoTime { parseBlocks(md) }
|
||||
|
||||
repeat(50) { parseBlocks(md) }
|
||||
val iterations = 200
|
||||
val warmNanos = measureNanoTime { repeat(iterations) { parseBlocks(md) } }
|
||||
|
||||
println("BENCHMARK_RESULT: parseBlocks 1000 bullets cold=${cold / 1_000_000.0}ms")
|
||||
println("BENCHMARK_RESULT: parseBlocks 1000 bullets warm avg=${warmNanos / iterations / 1_000_000.0}ms")
|
||||
}
|
||||
}
|
||||
@@ -459,6 +459,28 @@ class MarkdownTextFeatureTest {
|
||||
assertEquals(0, o16.level)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun quotedBlocksRetainNestedHeadingsListsAndFences() {
|
||||
val source = "> # Heading\n> - item\n> ```kotlin\n> val x = 1\n> ```"
|
||||
val quote = parseBlocks(source).single() as MdBlock.Quote
|
||||
val nested = parseBlocks(quote.text)
|
||||
assertTrue(nested.any { it is MdBlock.Heading && it.text == "Heading" })
|
||||
val bullet = nested.filterIsInstance<MdBlock.Bullet>().single()
|
||||
assertEquals("item", bullet.text)
|
||||
assertEquals("val x = 1", (parseBlocks(bullet.nestedSource).single() as MdBlock.Code).code)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun listItemsRetainFencedCodeAndHeadingsAsNestedBlocks() {
|
||||
val source = "- intro\n ```kotlin\n val x = 1\n ```\n # Inside\n- next"
|
||||
val bullets = parseBlocks(source).filterIsInstance<MdBlock.Bullet>()
|
||||
assertEquals(listOf("intro", "next"), bullets.map { it.text })
|
||||
val nested = parseBlocks(bullets.first().nestedSource)
|
||||
assertTrue(nested.any { it is MdBlock.Code && it.code == "val x = 1" })
|
||||
assertTrue(nested.any { it is MdBlock.Heading && it.text == "Inside" })
|
||||
assertEquals("", bullets.last().nestedSource)
|
||||
}
|
||||
|
||||
// 19. NESTED CODEBLOCKS & EXTENDED CODE FENCES
|
||||
@Test
|
||||
fun testNestedCodeBlock_fourBackticksWrappingThreeBackticks() {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class MediaSegmentsTest {
|
||||
private fun att(
|
||||
name: String,
|
||||
offset: Int?,
|
||||
) = Attachment(uri = "u/$name", name = name, mimeType = "image/png", contentOffset = offset)
|
||||
|
||||
private fun kinds(segments: List<MessageSegment>) =
|
||||
segments.map {
|
||||
when (it) {
|
||||
is MessageSegment.Text -> "T:${it.text}"
|
||||
is MessageSegment.Media -> "M:${it.attachment.name}"
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun extractRecordsOffsetsIntoStrippedText() {
|
||||
val text = "First: MEDIA:/a/1.png\nthen second: MEDIA:/a/2.png\nend"
|
||||
val stripped = HostMediaExtractor.strip(text)
|
||||
val items = HostMediaExtractor.extract(text)
|
||||
assertEquals(2, items.size)
|
||||
assertTrue(items[0].offset <= items[1].offset)
|
||||
assertTrue(items[1].offset <= stripped.length)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaLandsBetweenTheParagraphsItSatBetween() {
|
||||
val text = "Chart one:\nMEDIA:/a/1.png\nChart two:\nMEDIA:/a/2.png\nDone."
|
||||
val stripped = HostMediaExtractor.strip(text)
|
||||
val items = HostMediaExtractor.extract(text)
|
||||
val atts = items.map { att(it.path.substringAfterLast('/'), it.offset) }
|
||||
val out = kinds(splitByMedia(stripped, atts))
|
||||
assertEquals(listOf("T:Chart one:", "M:1.png", "T:Chart two:", "M:2.png", "T:Done."), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noOffsetAppendsAfterText() {
|
||||
val out = kinds(splitByMedia("Hello", listOf(att("x.png", null))))
|
||||
assertEquals(listOf("T:Hello", "M:x.png"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaOnlyMessageHasNoBlankText() {
|
||||
val out = kinds(splitByMedia("", listOf(att("x.png", 0))))
|
||||
assertEquals(listOf("M:x.png"), out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mediaDoesNotSplitAFencedCodeBlock() {
|
||||
val content = "Look:\n```\ncode line\nmore\n```\nafter"
|
||||
val out = kinds(splitByMedia(content, listOf(att("x.png", content.indexOf("code line")))))
|
||||
assertEquals(2, out.count { it.startsWith("T:") })
|
||||
assertTrue(out[0].contains("more") && out[0].endsWith("```"))
|
||||
assertEquals("M:x.png", out[1])
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,30 @@ class MediaTypesTest {
|
||||
assertEquals("application/octet-stream", mediaMimeForPath("/tmp/x.weird"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extension for mime uses one table plus aliases`() {
|
||||
// Table entries, including params and case.
|
||||
assertEquals("png", extensionForMime("image/png"))
|
||||
assertEquals("png", extensionForMime(" IMAGE/PNG; charset=binary"))
|
||||
assertEquals("ogg", extensionForMime("audio/ogg; codecs=opus"))
|
||||
assertEquals("mkv", extensionForMime("video/x-matroska"))
|
||||
assertEquals("pdf", extensionForMime("application/pdf"))
|
||||
// Aliases win over first-table-match.
|
||||
assertEquals("jpg", extensionForMime("image/jpeg"))
|
||||
assertEquals("jpg", extensionForMime("image/jpg"))
|
||||
assertEquals("heic", extensionForMime("image/heif"))
|
||||
assertEquals("mp3", extensionForMime("audio/mp3"))
|
||||
assertEquals("m4a", extensionForMime("audio/x-m4a"))
|
||||
assertEquals("wav", extensionForMime("audio/wave"))
|
||||
assertEquals("flac", extensionForMime("application/x-flac"))
|
||||
assertEquals("ogg", extensionForMime("application/ogg"))
|
||||
// Family fallbacks.
|
||||
assertEquals("mp4", extensionForMime("video/unknown"))
|
||||
assertEquals("mp3", extensionForMime("audio/unknown"))
|
||||
assertEquals("img", extensionForMime("image/unknown"))
|
||||
assertEquals("bin", extensionForMime("application/unknown"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `name extraction handles urls and paths`() {
|
||||
assertEquals("report.pdf", mediaNameFromPath("https://gw/api/files/download?path=%2Ftmp%2Freport.pdf"))
|
||||
|
||||
+76
-9
@@ -169,11 +169,29 @@ class ReadNotificationReviewRegressionTest {
|
||||
SessionMessage(id = 200, role = "assistant", content = JsonPrimitive("Done")),
|
||||
)
|
||||
for (latest in listOf(true, false)) {
|
||||
val mapped = mapServerMessages("session", rows, 10, latest, listOf(live), context = context)
|
||||
val mapped =
|
||||
mapServerMessages(
|
||||
"session",
|
||||
rows,
|
||||
10,
|
||||
latest,
|
||||
listOf(live),
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
assertEquals(listOf("comp", null), mapped.map { it.completionId })
|
||||
val merged = mergeTranscriptWithLive(mapped, listOf(live), preserveLiveIds = true)
|
||||
assertEquals(if (latest) "rest-session-100" else "rest-session-10", merged.single { it.id == "ws" }.restId)
|
||||
assertNull(mapServerMessages("session", rows.takeLast(1), 11, latest, listOf(live)).single().completionId)
|
||||
// Target row absent from this page: the reserved completion must fail closed, not rebind by text.
|
||||
val targetRowAbsent =
|
||||
mapServerMessages(
|
||||
"session",
|
||||
rows.takeLast(1),
|
||||
11,
|
||||
latest,
|
||||
listOf(live),
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
assertNull(targetRowAbsent.single().completionId)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +232,7 @@ class ReadNotificationReviewRegressionTest {
|
||||
0,
|
||||
true,
|
||||
current,
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(),
|
||||
)
|
||||
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true).single()
|
||||
assertEquals("ws", merged.id)
|
||||
@@ -256,7 +275,7 @@ class ReadNotificationReviewRegressionTest {
|
||||
latestPaging = true,
|
||||
liveMessages = current,
|
||||
isPagingOlder = false,
|
||||
context = context,
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
val merged = mergeTranscriptWithLive(mapped, current, preserveLiveIds = true)
|
||||
|
||||
@@ -685,7 +704,15 @@ class ReadNotificationReviewRegressionTest {
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
mapServerMessages(
|
||||
"session",
|
||||
history,
|
||||
0,
|
||||
true,
|
||||
emptyList(),
|
||||
isPagingOlder = false,
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
assertEquals("Older message must have null completionId", null, mapped[0].completionId)
|
||||
assertEquals("Newest message must receive target completionId", "comp-newest-done", mapped[1].completionId)
|
||||
|
||||
@@ -747,7 +774,15 @@ class ReadNotificationReviewRegressionTest {
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
mapServerMessages(
|
||||
"session",
|
||||
history,
|
||||
0,
|
||||
true,
|
||||
emptyList(),
|
||||
isPagingOlder = false,
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
assertEquals("comp-long-2", mapped[0].completionId)
|
||||
assertEquals(null, mapped[1].completionId)
|
||||
|
||||
@@ -796,7 +831,15 @@ class ReadNotificationReviewRegressionTest {
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
mapServerMessages(
|
||||
"session",
|
||||
history,
|
||||
0,
|
||||
true,
|
||||
emptyList(),
|
||||
isPagingOlder = false,
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
val row = mapped.single()
|
||||
assertEquals("comp-media-cold", row.completionId)
|
||||
assertEquals("Here is the image", row.content)
|
||||
@@ -839,7 +882,15 @@ class ReadNotificationReviewRegressionTest {
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
mapServerMessages(
|
||||
"session",
|
||||
history,
|
||||
0,
|
||||
true,
|
||||
emptyList(),
|
||||
isPagingOlder = false,
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
val row = mapped.single()
|
||||
assertEquals("comp-media-only-cold", row.completionId)
|
||||
assertEquals("", row.content)
|
||||
@@ -887,7 +938,15 @@ class ReadNotificationReviewRegressionTest {
|
||||
)
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
mapServerMessages(
|
||||
"session",
|
||||
history,
|
||||
0,
|
||||
true,
|
||||
emptyList(),
|
||||
isPagingOlder = false,
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
assertEquals("comp-media-dup-2", mapped[0].completionId)
|
||||
assertEquals(null, mapped[1].completionId)
|
||||
|
||||
@@ -955,7 +1014,15 @@ class ReadNotificationReviewRegressionTest {
|
||||
}
|
||||
|
||||
val mapped =
|
||||
mapServerMessages("session", history, 0, true, emptyList(), isPagingOlder = false, context = context)
|
||||
mapServerMessages(
|
||||
"session",
|
||||
history,
|
||||
0,
|
||||
true,
|
||||
emptyList(),
|
||||
isPagingOlder = false,
|
||||
activeReplyTarget = ReplyNotificationTracker.getActiveTarget(context),
|
||||
)
|
||||
assertEquals(listOf(null, null, "comp-turn"), mapped.map { it.completionId })
|
||||
|
||||
assertFalse(
|
||||
|
||||
@@ -12,6 +12,9 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.ConfigGetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConfigSetParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
|
||||
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
|
||||
import io.mockk.coEvery
|
||||
@@ -21,6 +24,7 @@ import io.mockk.mockkObject
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
@@ -32,8 +36,10 @@ import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
@@ -213,9 +219,9 @@ class SlashCommandDispatchRpcTest {
|
||||
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
|
||||
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
|
||||
val params = paramsCalls[dispatchIndex]
|
||||
assertEquals("help", params["name"])
|
||||
assertEquals("", params["arg"])
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals(JsonPrimitive("help"), params["name"])
|
||||
assertEquals(JsonPrimitive(""), params["arg"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -271,35 +277,27 @@ class SlashCommandDispatchRpcTest {
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
val setCalls = mutableListOf<ConfigSetParams>()
|
||||
coEvery {
|
||||
HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any())
|
||||
} answers {
|
||||
val result =
|
||||
if (arg<String>(0) == WsMethods.CONFIG_SET) {
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
|
||||
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
|
||||
}
|
||||
} else {
|
||||
kotlinx.serialization.json.JsonNull
|
||||
}
|
||||
CompletableDeferred<Any?>(result)
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
|
||||
put("value", kotlinx.serialization.json.JsonPrimitive("high"))
|
||||
}
|
||||
}
|
||||
|
||||
vm.sendMessage("/reasoning high --global")
|
||||
advanceUntilIdle()
|
||||
|
||||
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET)
|
||||
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0)
|
||||
val params = paramsCalls[configIndex]
|
||||
assertEquals("reasoning", params["key"])
|
||||
assertEquals("high", params["value"])
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals("global", params["scope"])
|
||||
assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size)
|
||||
val params = setCalls.single()
|
||||
assertEquals("reasoning", params.key)
|
||||
assertEquals("high", params.value)
|
||||
assertEquals(sessionId, params.sessionId)
|
||||
assertEquals("global", params.scope)
|
||||
assertEquals("high", vm.uiState.value.reasoningLevel)
|
||||
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||
verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -307,33 +305,26 @@ class SlashCommandDispatchRpcTest {
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
val setCalls = mutableListOf<ConfigSetParams>()
|
||||
coEvery {
|
||||
HermesWsClient.call(RpcMethods.CONFIG_SET, capture(setCalls), any(), any())
|
||||
} answers {
|
||||
val result =
|
||||
if (arg<String>(0) == WsMethods.CONFIG_SET) {
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
|
||||
put("value", kotlinx.serialization.json.JsonPrimitive("show"))
|
||||
}
|
||||
} else {
|
||||
kotlinx.serialization.json.JsonNull
|
||||
}
|
||||
CompletableDeferred<Any?>(result)
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("key", kotlinx.serialization.json.JsonPrimitive("reasoning"))
|
||||
put("value", kotlinx.serialization.json.JsonPrimitive("show"))
|
||||
}
|
||||
}
|
||||
|
||||
vm.sendMessage("/reasoning show")
|
||||
advanceUntilIdle()
|
||||
|
||||
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_SET)
|
||||
assertTrue("expected CONFIG_SET, got $methodCalls", configIndex >= 0)
|
||||
val params = paramsCalls[configIndex]
|
||||
assertEquals("reasoning", params["key"])
|
||||
assertEquals("show", params["value"])
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||
assertEquals("expected exactly one config.set, got $setCalls", 1, setCalls.size)
|
||||
val params = setCalls.single()
|
||||
assertEquals("reasoning", params.key)
|
||||
assertEquals("show", params.value)
|
||||
assertEquals(sessionId, params.sessionId)
|
||||
assertNull(params.scope)
|
||||
verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
|
||||
assertEquals(
|
||||
"reasoning: show",
|
||||
vm.uiState.value.messages
|
||||
@@ -347,32 +338,24 @@ class SlashCommandDispatchRpcTest {
|
||||
runTest {
|
||||
val (vm, sessionId) = createViewModelWithSession()
|
||||
|
||||
val methodCalls = mutableListOf<String>()
|
||||
val paramsCalls = mutableListOf<Map<String, Any>>()
|
||||
every {
|
||||
HermesWsClient.request(capture(methodCalls), capture(paramsCalls), any())
|
||||
val getCalls = mutableListOf<ConfigGetParams>()
|
||||
coEvery {
|
||||
HermesWsClient.call(RpcMethods.CONFIG_GET, capture(getCalls), any(), any())
|
||||
} answers {
|
||||
val result =
|
||||
if (arg<String>(0) == WsMethods.CONFIG_GET) {
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("value", kotlinx.serialization.json.JsonPrimitive("ultra"))
|
||||
put("display", kotlinx.serialization.json.JsonPrimitive("hide"))
|
||||
}
|
||||
} else {
|
||||
kotlinx.serialization.json.JsonNull
|
||||
}
|
||||
CompletableDeferred<Any?>(result)
|
||||
kotlinx.serialization.json.buildJsonObject {
|
||||
put("value", kotlinx.serialization.json.JsonPrimitive("ultra"))
|
||||
put("display", kotlinx.serialization.json.JsonPrimitive("hide"))
|
||||
}
|
||||
}
|
||||
|
||||
vm.sendMessage("/reasoning")
|
||||
advanceUntilIdle()
|
||||
|
||||
val configIndex = methodCalls.indexOf(WsMethods.CONFIG_GET)
|
||||
assertTrue("expected CONFIG_GET, got $methodCalls", configIndex >= 0)
|
||||
val params = paramsCalls[configIndex]
|
||||
assertEquals("reasoning", params["key"])
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertTrue(WsMethods.COMMAND_DISPATCH !in methodCalls)
|
||||
assertEquals("expected exactly one config.get, got $getCalls", 1, getCalls.size)
|
||||
val params = getCalls.single()
|
||||
assertEquals("reasoning", params.key)
|
||||
assertEquals(sessionId, params.sessionId)
|
||||
verify(exactly = 0) { HermesWsClient.send(WsMethods.COMMAND_DISPATCH, any(), any()) }
|
||||
assertEquals(
|
||||
"reasoning: ultra · display hide",
|
||||
vm.uiState.value.messages
|
||||
@@ -500,8 +483,8 @@ class SlashCommandDispatchRpcTest {
|
||||
assertTrue("expected SLASH_EXEC fallback, got $methodCalls", execIndex >= 0)
|
||||
assertTrue("dispatch must precede fallback", dispatchIndex < execIndex)
|
||||
val params = paramsCalls[execIndex]
|
||||
assertEquals("/status", params["command"])
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals(JsonPrimitive("/status"), params["command"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
|
||||
// And the slash.exec output must be surfaced to the user.
|
||||
val last =
|
||||
@@ -647,9 +630,9 @@ class SlashCommandDispatchRpcTest {
|
||||
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
|
||||
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
|
||||
val params = paramsCalls[dispatchIndex]
|
||||
assertEquals("init", params["name"])
|
||||
assertEquals("", params["arg"])
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals(JsonPrimitive("init"), params["name"])
|
||||
assertEquals(JsonPrimitive(""), params["arg"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -672,9 +655,9 @@ class SlashCommandDispatchRpcTest {
|
||||
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
|
||||
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
|
||||
val params = paramsCalls[dispatchIndex]
|
||||
assertEquals("init", params["name"])
|
||||
assertEquals("extra context here", params["arg"])
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals(JsonPrimitive("init"), params["name"])
|
||||
assertEquals(JsonPrimitive("extra context here"), params["arg"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -700,9 +683,9 @@ class SlashCommandDispatchRpcTest {
|
||||
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
|
||||
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
|
||||
val params = paramsCalls[dispatchIndex]
|
||||
assertEquals("focus", params["name"])
|
||||
assertEquals("", params["arg"])
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals(JsonPrimitive("focus"), params["name"])
|
||||
assertEquals(JsonPrimitive(""), params["arg"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -725,9 +708,9 @@ class SlashCommandDispatchRpcTest {
|
||||
val dispatchIndex = methodCalls.indexOf(WsMethods.COMMAND_DISPATCH)
|
||||
assertTrue("expected COMMAND_DISPATCH, got $methodCalls", dispatchIndex >= 0)
|
||||
val params = paramsCalls[dispatchIndex]
|
||||
assertEquals("focus", params["name"])
|
||||
assertEquals("on", params["arg"])
|
||||
assertEquals(sessionId, params["session_id"])
|
||||
assertEquals(JsonPrimitive("focus"), params["name"])
|
||||
assertEquals(JsonPrimitive("on"), params["arg"])
|
||||
assertEquals(JsonPrimitive(sessionId), params["session_id"])
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -20,8 +20,8 @@ class SlashCommandDispatcherTest {
|
||||
private val dispatcher = SlashCommandDispatcher()
|
||||
|
||||
@Test
|
||||
fun `stop routes to Interrupt`() {
|
||||
assertEquals(SlashResult.Interrupt, dispatcher.dispatch("/stop"))
|
||||
fun `stop routes to Stop`() {
|
||||
assertEquals(SlashResult.Stop, dispatcher.dispatch("/stop"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class StreamingBufferTest {
|
||||
@Test
|
||||
fun channelsThrottleIndependentlyAndFlushCumulativeText() =
|
||||
runTest {
|
||||
var now = 100L
|
||||
val tokens = mutableListOf<String>()
|
||||
val reasoning = mutableListOf<String>()
|
||||
val tokenBuffer = StreamingBuffer(this, 33L, { now }, tokens::add)
|
||||
val reasoningBuffer = StreamingBuffer(this, 33L, { now }, reasoning::add)
|
||||
|
||||
tokenBuffer.append("A", flushImmediately = false)
|
||||
tokenBuffer.append("B", flushImmediately = false)
|
||||
reasoningBuffer.append("R", flushImmediately = false)
|
||||
assertEquals(listOf("A"), tokens)
|
||||
assertEquals(listOf("R"), reasoning)
|
||||
|
||||
now += 34
|
||||
testScheduler.advanceTimeBy(34L)
|
||||
assertEquals(listOf("A", "AB"), tokens)
|
||||
assertEquals(listOf("R"), reasoning)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun transitionFlushCancelsTrailingJobWithoutDroppingTheNextDelta() =
|
||||
runTest {
|
||||
val output = mutableListOf<String>()
|
||||
val buffer = StreamingBuffer(this, 33L, { 100L }, output::add)
|
||||
buffer.append("A", flushImmediately = false)
|
||||
buffer.append("B", flushImmediately = false)
|
||||
buffer.flushPending()
|
||||
testScheduler.advanceTimeBy(34L)
|
||||
assertEquals(listOf("A", "AB"), output)
|
||||
|
||||
buffer.append("C", flushImmediately = true)
|
||||
assertEquals(listOf("A", "AB", "ABC"), output)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearCancelsTrailingJobAndStartsFresh() =
|
||||
runTest {
|
||||
val output = mutableListOf<String>()
|
||||
val buffer = StreamingBuffer(this, 33L, { 100L }, output::add)
|
||||
buffer.append("Old", flushImmediately = false)
|
||||
buffer.append(" tail", flushImmediately = false)
|
||||
buffer.clear()
|
||||
testScheduler.advanceTimeBy(34L)
|
||||
buffer.append("Fresh", flushImmediately = false)
|
||||
assertEquals(listOf("Old", "Fresh"), output)
|
||||
}
|
||||
}
|
||||
+248
@@ -0,0 +1,248 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.lazy.LazyListItemInfo
|
||||
import androidx.compose.foundation.lazy.LazyListLayoutInfo
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.snapshots.Snapshot
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class ChatScrollControllerTest {
|
||||
private val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun mockLazyListStateAtBottom(): LazyListState {
|
||||
val listState = mockk<LazyListState>(relaxed = true)
|
||||
val layoutInfo = mockk<LazyListLayoutInfo>(relaxed = true)
|
||||
val itemInfo = mockk<LazyListItemInfo>(relaxed = true)
|
||||
|
||||
every { layoutInfo.totalItemsCount } returns 5
|
||||
every { layoutInfo.viewportEndOffset } returns 1000
|
||||
every { layoutInfo.afterContentPadding } returns 0
|
||||
every { itemInfo.index } returns 4
|
||||
every { itemInfo.offset } returns 800
|
||||
every { itemInfo.size } returns 100
|
||||
every { layoutInfo.visibleItemsInfo } returns listOf(itemInfo)
|
||||
every { listState.layoutInfo } returns layoutInfo
|
||||
|
||||
return listState
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state has isFollowingBottom true and pendingCount 0`() {
|
||||
val listState = mockk<LazyListState>(relaxed = true)
|
||||
val scope = TestScope(testDispatcher)
|
||||
val controller = ChatScrollController(listState, scope)
|
||||
|
||||
assertTrue(controller.isFollowingBottom)
|
||||
assertEquals(0, controller.pendingCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `new tail while following keeps pendingCount 0 and requests scroll`() =
|
||||
runTest(testDispatcher) {
|
||||
val listState = mockLazyListStateAtBottom()
|
||||
val controller = ChatScrollController(listState, this)
|
||||
|
||||
controller.onTailChanged(tailKey = "msg-1", messageCount = 1)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(controller.isFollowingBottom)
|
||||
assertEquals(0, controller.pendingCount)
|
||||
coVerify(atLeast = 1) { listState.scrollToItem(4, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `pauseFollowing followed by new messages increments pendingCount`() {
|
||||
val listState = mockk<LazyListState>(relaxed = true)
|
||||
val scope = TestScope(testDispatcher)
|
||||
val controller = ChatScrollController(listState, scope)
|
||||
|
||||
controller.pauseFollowing()
|
||||
assertFalse(controller.isFollowingBottom)
|
||||
|
||||
controller.onTailChanged(tailKey = "msg-1", messageCount = 1)
|
||||
assertEquals(1, controller.pendingCount)
|
||||
|
||||
controller.onTailChanged(tailKey = "msg-2", messageCount = 3)
|
||||
assertEquals(3, controller.pendingCount)
|
||||
assertFalse(controller.isFollowingBottom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `resumeFollowing clears pendingCount and restores isFollowingBottom`() =
|
||||
runTest(testDispatcher) {
|
||||
val listState = mockLazyListStateAtBottom()
|
||||
val controller = ChatScrollController(listState, this)
|
||||
|
||||
controller.pauseFollowing()
|
||||
controller.onTailChanged(tailKey = "msg-1", messageCount = 2)
|
||||
assertEquals(2, controller.pendingCount)
|
||||
assertFalse(controller.isFollowingBottom)
|
||||
|
||||
controller.resumeFollowing()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(controller.isFollowingBottom)
|
||||
assertEquals(0, controller.pendingCount)
|
||||
coVerify(atLeast = 1) { listState.animateScrollToItem(4, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `jumpToBottom clears pendingCount and restores isFollowingBottom`() =
|
||||
runTest(testDispatcher) {
|
||||
val listState = mockLazyListStateAtBottom()
|
||||
val controller = ChatScrollController(listState, this)
|
||||
|
||||
controller.pauseFollowing()
|
||||
controller.onTailChanged(tailKey = "msg-1", messageCount = 4)
|
||||
assertEquals(4, controller.pendingCount)
|
||||
assertFalse(controller.isFollowingBottom)
|
||||
|
||||
controller.jumpToBottom(animated = false)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(controller.isFollowingBottom)
|
||||
assertEquals(0, controller.pendingCount)
|
||||
coVerify(atLeast = 1) { listState.scrollToItem(4, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tail key unchanged causes no change to pendingCount or scroll`() =
|
||||
runTest(testDispatcher) {
|
||||
val listState = mockLazyListStateAtBottom()
|
||||
val controller = ChatScrollController(listState, this)
|
||||
|
||||
controller.pauseFollowing()
|
||||
controller.onTailChanged(tailKey = "same-key", messageCount = 1)
|
||||
assertEquals(1, controller.pendingCount)
|
||||
|
||||
// Repeating the same tailKey should be a no-op
|
||||
controller.onTailChanged(tailKey = "same-key", messageCount = 5)
|
||||
assertEquals(1, controller.pendingCount)
|
||||
|
||||
advanceUntilIdle()
|
||||
coVerify(exactly = 0) { listState.scrollToItem(any(), any()) }
|
||||
coVerify(exactly = 0) { listState.animateScrollToItem(any(), any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `layout growth does not pause follow but returning to bottom after user gesture resumes`() =
|
||||
runTest(testDispatcher) {
|
||||
var atBottom by mutableStateOf(true)
|
||||
val listState = mockLazyListStateAtBottom()
|
||||
val layoutInfo = listState.layoutInfo
|
||||
val itemInfo = layoutInfo.visibleItemsInfo.single()
|
||||
every { itemInfo.index } answers { if (atBottom) 4 else 3 }
|
||||
val controller = ChatScrollController(listState, backgroundScope)
|
||||
|
||||
controller.observeUserScrollPosition()
|
||||
runCurrent()
|
||||
atBottom = false // A new row laid out below the viewport, not a user scroll.
|
||||
Snapshot.sendApplyNotifications()
|
||||
runCurrent()
|
||||
assertTrue(controller.isFollowingBottom)
|
||||
|
||||
controller.onUserScrollUp()
|
||||
assertFalse(controller.isFollowingBottom)
|
||||
atBottom = true
|
||||
Snapshot.sendApplyNotifications()
|
||||
runCurrent()
|
||||
assertTrue(controller.isFollowingBottom)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `user upward gesture cancels in-flight follow and counts new messages`() =
|
||||
runTest(testDispatcher) {
|
||||
val listState = mockLazyListStateAtBottom()
|
||||
coEvery { listState.scrollToItem(4, any()) } coAnswers { delay(1_000) }
|
||||
val controller = ChatScrollController(listState, this)
|
||||
|
||||
controller.onTailChanged(tailKey = "first", messageCount = 1)
|
||||
runCurrent()
|
||||
controller.onUserScrollUp()
|
||||
controller.onTailChanged(tailKey = "second", messageCount = 2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(controller.isFollowingBottom)
|
||||
assertEquals(1, controller.pendingCount)
|
||||
coVerify(exactly = 1) { listState.scrollToItem(4, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `historical jump replaces an in-flight follow and leaves follow paused`() =
|
||||
runTest(testDispatcher) {
|
||||
val listState = mockLazyListStateAtBottom()
|
||||
coEvery { listState.scrollToItem(4, any()) } coAnswers { delay(1_000) }
|
||||
val controller = ChatScrollController(listState, this)
|
||||
|
||||
controller.onTailChanged(tailKey = "tail", messageCount = 1)
|
||||
runCurrent()
|
||||
controller.jumpToHistoryStart()
|
||||
advanceUntilIdle()
|
||||
|
||||
assertFalse(controller.isFollowingBottom)
|
||||
coVerify(exactly = 1) { listState.scrollToItem(0, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `rapid tails replace older jobs rather than queueing scrolls`() =
|
||||
runTest(testDispatcher) {
|
||||
val listState = mockLazyListStateAtBottom()
|
||||
val controller = ChatScrollController(listState, this)
|
||||
|
||||
controller.onTailChanged(tailKey = "one", messageCount = 1)
|
||||
controller.onTailChanged(tailKey = "two", messageCount = 2)
|
||||
advanceUntilIdle()
|
||||
|
||||
assertTrue(controller.isFollowingBottom)
|
||||
coVerify(exactly = 1) { listState.scrollToItem(4, any()) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `showFab returns true only when paused and content is present`() {
|
||||
val listState = mockk<LazyListState>(relaxed = true)
|
||||
val scope = TestScope(testDispatcher)
|
||||
val controller = ChatScrollController(listState, scope)
|
||||
|
||||
// Following bottom -> false regardless of contentPresent
|
||||
assertFalse(controller.showFab(contentPresent = true))
|
||||
assertFalse(controller.showFab(contentPresent = false))
|
||||
|
||||
controller.pauseFollowing()
|
||||
// Paused -> true only if contentPresent
|
||||
assertTrue(controller.showFab(contentPresent = true))
|
||||
assertFalse(controller.showFab(contentPresent = false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.ChatTimelineState
|
||||
import com.m57.hermescontrol.ui.chat.ChatUiState
|
||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class TranscriptUiStateTest {
|
||||
private val live = ChatMessage(id = "live", role = MessageRole.USER, content = "hello")
|
||||
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
|
||||
private val clarify = ClarifyUi(text = "choose")
|
||||
|
||||
@Test
|
||||
fun `live state preserves prompts streaming and paging actions`() {
|
||||
val stream = StreamingState()
|
||||
val result =
|
||||
TranscriptUiState.resolve(
|
||||
chat =
|
||||
ChatUiState(
|
||||
messages = listOf(live),
|
||||
currentSessionId = "session",
|
||||
isAgentTyping = true,
|
||||
isLoading = true,
|
||||
isLoadingOlder = true,
|
||||
hasOlderMessages = true,
|
||||
clarifyRequest = clarify,
|
||||
isCompressing = true,
|
||||
savingAttachmentPath = "server-path",
|
||||
),
|
||||
timeline = ChatTimelineState(),
|
||||
streaming = stream,
|
||||
savingAttachmentPath = "local-path",
|
||||
speakingMessageId = "live",
|
||||
)
|
||||
assertEquals(listOf(live), result.messages)
|
||||
assertSame(stream, result.streamingState)
|
||||
assertEquals("session", result.pagingSessionId)
|
||||
assertTrue(result.isAgentTyping)
|
||||
assertTrue(result.isLoading)
|
||||
assertTrue(result.isLoadingOlder)
|
||||
assertTrue(result.hasOlderMessages)
|
||||
assertSame(clarify, result.clarifyRequest)
|
||||
assertTrue(result.isCompressing)
|
||||
assertEquals("local-path", result.savingAttachmentPath)
|
||||
assertEquals("live", result.speakingMessageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `historical state isolates live tail prompts loading and compression`() {
|
||||
val result =
|
||||
TranscriptUiState.resolve(
|
||||
chat =
|
||||
ChatUiState(
|
||||
messages = listOf(live),
|
||||
currentSessionId = "session",
|
||||
isAgentTyping = true,
|
||||
isLoading = true,
|
||||
isLoadingOlder = true,
|
||||
hasOlderMessages = true,
|
||||
clarifyRequest = clarify,
|
||||
isCompressing = true,
|
||||
compressionStatus = "compressing",
|
||||
),
|
||||
timeline = ChatTimelineState(historyMessages = listOf(historical), historyAnchorRowId = 42),
|
||||
streaming = StreamingState(streamingMessage = live),
|
||||
savingAttachmentPath = null,
|
||||
speakingMessageId = null,
|
||||
)
|
||||
assertEquals(listOf(historical), result.messages)
|
||||
assertNull(result.streamingState.streamingMessage)
|
||||
assertEquals("session:history:42", result.pagingSessionId)
|
||||
assertFalse(result.isAgentTyping)
|
||||
assertFalse(result.isLoading)
|
||||
assertFalse(result.isLoadingOlder)
|
||||
assertFalse(result.hasOlderMessages)
|
||||
assertNull(result.clarifyRequest)
|
||||
assertFalse(result.isCompressing)
|
||||
assertNull(result.compressionStatus)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.AccountTree
|
||||
import androidx.compose.material.icons.filled.Build
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.Terminal
|
||||
import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.BrowserTypeRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.FileEditRenderer
|
||||
import com.m57.hermescontrol.ui.chat.tool.render.TerminalRenderer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Test
|
||||
|
||||
class ToolCatalogTest {
|
||||
@Test
|
||||
fun `icon and renderer lookups read the same catalog entry`() {
|
||||
for ((name, def) in ToolCatalog.entries) {
|
||||
assertSame(name, def.icon, ToolSchemaRegistry.getDisplayConfig(name).icon)
|
||||
assertSame(name, def.renderer, ToolRendererRegistry.rendererFor(name))
|
||||
assertEquals(name, ToolSchemaRegistry.getDisplayConfig(name).name)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tools that drifted now have a real icon`() {
|
||||
assertSame(Icons.Filled.Edit, ToolSchemaRegistry.getDisplayConfig("edit_file").icon)
|
||||
assertSame(Icons.Filled.Keyboard, ToolSchemaRegistry.getDisplayConfig("browser_fill").icon)
|
||||
for (name in listOf("project_list", "project_create", "project_switch")) {
|
||||
assertSame(name, Icons.Filled.AccountTree, ToolSchemaRegistry.getDisplayConfig(name).icon)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `known tools keep their renderers and icons`() {
|
||||
assertSame(TerminalRenderer, ToolRendererRegistry.rendererFor("terminal"))
|
||||
assertSame(Icons.Filled.Terminal, ToolSchemaRegistry.getDisplayConfig("terminal").icon)
|
||||
assertSame(FileEditRenderer, ToolRendererRegistry.rendererFor("edit_file"))
|
||||
assertSame(BrowserTypeRenderer, ToolRendererRegistry.rendererFor("browser_fill"))
|
||||
// Icon-only tools still render generically.
|
||||
assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("clarify"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown tools fall back to build icon and generic renderer`() {
|
||||
assertSame(Icons.Filled.Build, ToolSchemaRegistry.getDisplayConfig("mystery").icon)
|
||||
assertEquals("mystery", ToolSchemaRegistry.getDisplayConfig("mystery").name)
|
||||
assertEquals("tool", ToolSchemaRegistry.getDisplayConfig(null).name)
|
||||
assertSame(GenericToolRenderer, ToolRendererRegistry.rendererFor("mystery"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.m57.hermescontrol.ui.chat.tool
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.parseToolOutput
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotSame
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
|
||||
class ToolViewCacheTest {
|
||||
private val payload = """{"name":"terminal","args":{"command":"ls"},"result":{"output":"a","exit_code":0}}"""
|
||||
|
||||
@Before
|
||||
fun reset() = ToolViewCache.clear()
|
||||
|
||||
@Test
|
||||
fun `cached view equals a fresh parse`() {
|
||||
val cached = ToolViewCache.getOrParse(payload, "terminal", false)
|
||||
assertEquals(parseToolOutput(payload, "terminal", false), cached)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prewarm makes composition read a hit`() {
|
||||
ToolViewCache.prewarm(payload, "terminal", false)
|
||||
val first = ToolViewCache.getOrParse(payload, "terminal", false)
|
||||
assertSame(first, ToolViewCache.getOrParse(payload, "terminal", false))
|
||||
assertEquals(1, ToolViewCache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `running state and content changes are separate entries`() {
|
||||
val running = ToolViewCache.getOrParse(payload, "terminal", true)
|
||||
val done = ToolViewCache.getOrParse(payload, "terminal", false)
|
||||
assertNotSame(running, done)
|
||||
assertEquals(2, ToolViewCache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non json content is cached as null`() {
|
||||
assertNull(ToolViewCache.getOrParse("plain text", "terminal", false))
|
||||
assertNull(ToolViewCache.getOrParse("plain text", "terminal", false))
|
||||
assertEquals(1, ToolViewCache.size())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cache is bounded`() {
|
||||
repeat(600) { i -> ToolViewCache.getOrParse("""{"i":$i}""", "terminal", false) }
|
||||
assertEquals(512, ToolViewCache.size())
|
||||
}
|
||||
}
|
||||
+9
-8
@@ -10,7 +10,9 @@ import com.m57.hermescontrol.data.model.ConnectorPolicy
|
||||
import com.m57.hermescontrol.data.ws.AccountConnectorRepository
|
||||
import com.m57.hermescontrol.data.ws.ConnectionOperationParser
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionAnswer
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectionRespondParams
|
||||
import com.m57.hermescontrol.data.ws.contract.ConnectorOwner
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.coVerify
|
||||
import io.mockk.mockk
|
||||
@@ -143,7 +145,7 @@ class AccountConnectorsViewModelTest {
|
||||
runTest(dispatcher) {
|
||||
advanceUntilIdle()
|
||||
coEvery { repo.connect(any(), any()) } returns AccountConnectorResult.Success(snapshot())
|
||||
coEvery { repo.operationRequest(any(), any()) } returns mapOf("status" to "ok")
|
||||
coEvery { repo.operationRespond(any()) } returns mapOf("status" to "ok")
|
||||
coEvery { repo.operationStatus("op-a") } returns
|
||||
AccountConnectorResult.Success(snapshot().copy(seq = 2, settled = true))
|
||||
vm.connect("drive")
|
||||
@@ -151,12 +153,11 @@ class AccountConnectorsViewModelTest {
|
||||
vm.continueOperation()
|
||||
advanceUntilIdle()
|
||||
coVerify {
|
||||
repo.operationRequest(
|
||||
WsMethods.CONNECTION_RESPOND,
|
||||
mapOf(
|
||||
"owner" to mapOf("type" to "account"),
|
||||
"op_id" to "op-a",
|
||||
"result" to mapOf("settled_by" to "continue"),
|
||||
repo.operationRespond(
|
||||
ConnectionRespondParams(
|
||||
owner = ConnectorOwner.account(),
|
||||
opId = "op-a",
|
||||
result = ConnectionAnswer(settledBy = "continue"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.kanban
|
||||
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.InMemoryKanbanPreferencesStore
|
||||
import com.m57.hermescontrol.data.model.BulkTaskResult
|
||||
import com.m57.hermescontrol.data.model.BulkTasksResponse
|
||||
@@ -25,6 +26,7 @@ import io.mockk.mockk
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -43,13 +45,19 @@ class KanbanMutationTest {
|
||||
private val mockEventsClient = mockk<KanbanEventsClient>(relaxed = true)
|
||||
private val preferences = InMemoryKanbanPreferencesStore()
|
||||
|
||||
private val createdViewModels = mutableListOf<KanbanViewModel>()
|
||||
|
||||
private fun createViewModel(): KanbanViewModel =
|
||||
KanbanViewModel(
|
||||
repository = mockRepository,
|
||||
preferences = preferences,
|
||||
eventsClientProvider = { mockEventsClient },
|
||||
endpointProvider = { "http://127.0.0.1:9119" },
|
||||
)
|
||||
).also {
|
||||
// Keep all VM work on the test scheduler; real Dispatchers.IO races resetMain().
|
||||
it.ioDispatcher = testDispatcher
|
||||
createdViewModels += it
|
||||
}
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
@@ -58,6 +66,9 @@ class KanbanMutationTest {
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
testDispatcher.scheduler.advanceUntilIdle()
|
||||
createdViewModels.forEach { it.viewModelScope.cancel() }
|
||||
createdViewModels.clear()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.mcp
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.McpServer
|
||||
import com.m57.hermescontrol.data.model.McpServerTestResponse
|
||||
import com.m57.hermescontrol.data.model.McpServerUpdateRequest
|
||||
import com.m57.hermescontrol.data.model.McpServersResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.HermesApiService
|
||||
@@ -122,10 +123,10 @@ class McpOwnershipTest {
|
||||
vm.updateEnvValue("value")
|
||||
vm.addEnvVar(config.name)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
coVerify { api.updateMcpServer(config.name, mapOf("env" to mapOf("KEY" to "value"))) }
|
||||
coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = mapOf("KEY" to "value"))) }
|
||||
vm.removeEnvVar(config.name, "KEY")
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
coVerify { api.updateMcpServer(config.name, mapOf("env" to emptyMap<String, String>())) }
|
||||
coVerify { api.updateMcpServer(config.name, McpServerUpdateRequest(env = emptyMap())) }
|
||||
coEvery { api.deleteMcpServer(config.name) } returns Response.success(Unit)
|
||||
vm.deleteServer(config.name)
|
||||
dispatcher.scheduler.advanceUntilIdle()
|
||||
|
||||
@@ -3,17 +3,23 @@ package com.m57.hermescontrol.ui.process
|
||||
import com.m57.hermescontrol.data.model.ProcessInfo
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsMethods
|
||||
import io.mockk.every
|
||||
import com.m57.hermescontrol.data.ws.contract.ProcessKillParams
|
||||
import com.m57.hermescontrol.data.ws.contract.RpcMethods
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionIdParams
|
||||
import io.mockk.coEvery
|
||||
import io.mockk.mockkObject
|
||||
import io.mockk.slot
|
||||
import io.mockk.unmockkAll
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonElement
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.put
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
@@ -42,34 +48,40 @@ class ProcessesViewModelTest {
|
||||
|
||||
@Test
|
||||
fun `process list parses running and exited entries`() {
|
||||
val raw =
|
||||
mapOf(
|
||||
"processes" to
|
||||
listOf(
|
||||
mapOf(
|
||||
"session_id" to "p1",
|
||||
"command" to "python train.py --epochs 10",
|
||||
"pid" to 1234,
|
||||
"status" to "running",
|
||||
"uptime_seconds" to 95,
|
||||
"cwd" to "/work",
|
||||
),
|
||||
mapOf(
|
||||
"session_id" to "p2",
|
||||
"command" to "echo done",
|
||||
"status" to "exited",
|
||||
"exit_code" to 0,
|
||||
),
|
||||
),
|
||||
)
|
||||
val raw: JsonElement =
|
||||
buildJsonObject {
|
||||
put(
|
||||
"processes",
|
||||
buildJsonArray {
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("session_id", "p1")
|
||||
put("command", "python train.py --epochs 10")
|
||||
put("pid", 1234)
|
||||
put("status", "running")
|
||||
put("uptime_seconds", 95)
|
||||
put("cwd", "/work")
|
||||
},
|
||||
)
|
||||
add(
|
||||
buildJsonObject {
|
||||
put("session_id", "p2")
|
||||
put("command", "echo done")
|
||||
put("status", "exited")
|
||||
put("exit_code", 0)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.PROCESS_LIST, any())
|
||||
} returns CompletableDeferred(raw)
|
||||
val listParams = slot<SessionIdParams>()
|
||||
coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, capture(listParams), any(), any()) } returns raw
|
||||
|
||||
val vm = ProcessesViewModel()
|
||||
vm.load()
|
||||
|
||||
assertEquals(SessionIdParams("sess-1"), listParams.captured)
|
||||
val procs = vm.uiState.value.processes
|
||||
assertEquals(2, procs.size)
|
||||
|
||||
@@ -90,26 +102,20 @@ class ProcessesViewModelTest {
|
||||
@Test
|
||||
fun `kill issues process kill with session-scoped params then refreshes`() {
|
||||
var listCalls = 0
|
||||
val killParamsSlot = slot<Map<String, Any>>()
|
||||
val killParamsSlot = slot<ProcessKillParams>()
|
||||
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.PROCESS_LIST, any())
|
||||
} answers {
|
||||
coEvery { HermesWsClient.call(RpcMethods.PROCESS_LIST, any(), any(), any()) } answers {
|
||||
listCalls++
|
||||
CompletableDeferred(mapOf("processes" to emptyList<Any>()))
|
||||
buildJsonObject { put("processes", JsonArray(emptyList())) }
|
||||
}
|
||||
every {
|
||||
HermesWsClient.request(WsMethods.PROCESS_KILL, capture(killParamsSlot))
|
||||
} answers {
|
||||
CompletableDeferred(mapOf("killed" to true))
|
||||
coEvery { HermesWsClient.call(RpcMethods.PROCESS_KILL, capture(killParamsSlot), any(), any()) } answers {
|
||||
buildJsonObject { put("killed", true) }
|
||||
}
|
||||
|
||||
val vm = ProcessesViewModel()
|
||||
vm.kill("p1")
|
||||
|
||||
val killParams = killParamsSlot.captured
|
||||
assertEquals("p1", killParams["process_id"])
|
||||
assertEquals("sess-1", killParams["session_id"])
|
||||
assertEquals(ProcessKillParams(sessionId = "sess-1", processId = "p1"), killParamsSlot.captured)
|
||||
// list is called once on init (session emitted) + once after kill refresh
|
||||
assertTrue(listCalls >= 2)
|
||||
assertNull(vm.uiState.value.killingId)
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.m57.hermescontrol.ui.sessions
|
||||
|
||||
import com.m57.hermescontrol.data.model.SessionInfo
|
||||
import com.m57.hermescontrol.data.model.SessionSearchResult
|
||||
import com.m57.hermescontrol.ui.sessions.components.sourceLabel
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SessionSourceFilterTest {
|
||||
private val rows =
|
||||
listOf(
|
||||
SessionInfo("a", source = "cli"),
|
||||
SessionInfo("b", source = "CLI"),
|
||||
SessionInfo("c", source = "desktop"),
|
||||
SessionInfo("d", source = "api_server"),
|
||||
SessionInfo("e", source = "desktop", hidden = true),
|
||||
SessionInfo("f", source = null),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun availableSources_onlyLoadedVisibleSources_orderedByCountThenName() {
|
||||
val state = SessionsUiState(sessions = rows)
|
||||
assertEquals(listOf("cli", "api_server", "desktop"), state.availableSources)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun availableSources_includesHiddenOnlyWhenShown() {
|
||||
val onlyHidden = listOf(SessionInfo("x", source = "tui", hidden = true), SessionInfo("y", source = "cli"))
|
||||
assertEquals(listOf("cli"), SessionsUiState(sessions = onlyHidden).availableSources)
|
||||
assertEquals(
|
||||
listOf("cli", "tui"),
|
||||
SessionsUiState(sessions = onlyHidden, showHidden = true).availableSources,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun filter_isCaseInsensitiveAndNarrowsList() {
|
||||
val state = SessionsUiState(sessions = rows, sourceFilter = "cli")
|
||||
assertEquals(listOf("a", "b"), state.displaySessions.map { it.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun staleFilter_fallsBackToAll() {
|
||||
val state = SessionsUiState(sessions = rows, sourceFilter = "telegram")
|
||||
assertNull(state.activeSourceFilter)
|
||||
assertEquals(5, state.displaySessions.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun searchMode_usesSearchResultSources() {
|
||||
val state =
|
||||
SessionsUiState(
|
||||
searchQuery = "q",
|
||||
sessions = rows,
|
||||
searchResults =
|
||||
listOf(
|
||||
SessionSearchResult("s1", source = "telegram"),
|
||||
SessionSearchResult("s2", source = "cli"),
|
||||
),
|
||||
sourceFilter = "telegram",
|
||||
)
|
||||
assertEquals(listOf("cli", "telegram"), state.availableSources)
|
||||
assertEquals(listOf("s1"), displayedSessions(state).map { it.session.id })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun sourceLabel_mapsKnownAndTitleCasesUnknown() {
|
||||
assertEquals("API", sourceLabel("api_server"))
|
||||
assertEquals("Desktop", sourceLabel("desktop"))
|
||||
assertEquals("TUI", sourceLabel("tui"))
|
||||
assertEquals("Whatsapp cloud", sourceLabel("whatsapp_cloud"))
|
||||
assertEquals("Unknown", sourceLabel(null))
|
||||
}
|
||||
}
|
||||
@@ -93,6 +93,8 @@ class SettingsViewModelTest {
|
||||
every { AuthManager.isTokensPerSecondEnabled() } returns true
|
||||
every { AuthManager.isModelProviderShown() } returns false
|
||||
every { AuthManager.isKeepConnectedInBackground() } returns false
|
||||
every { AuthManager.isNotifySessionCompletions() } returns false
|
||||
every { AuthManager.setNotifySessionCompletions(any()) } returns Unit
|
||||
every { AuthManager.getConnectionProfiles() } returns emptyList()
|
||||
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
|
||||
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
|
||||
|
||||
Reference in new issue
Block a user