feat(#1218): support backend-owned connector setup operations (#1229)

This commit is contained in:
m57 authored and GitHub committed 2026-09-22 20:20:44 +03:00
1 parent bf53905a93
commit f34292aa83
21 files changed
+1872 -7

No files matched your search

@@ -60,6 +60,8 @@ class ChatScreenTest {
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
// ActionProgressDialog collects the controller's StateFlow — a relaxed
// mock proxy would crash the cast inside collectAsStateWithLifecycle.
// A real controller (never started here) stays invisible.
@@ -107,6 +109,8 @@ class ChatScreenTest {
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
every { mockViewModel.connectionOperationState } returns
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
every { mockViewModel.actionProgress } returns
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
@@ -0,0 +1,268 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertIsNotEnabled
import androidx.compose.ui.test.assertTextContains
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performTextInput
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.ConnectionEnvField
import com.m57.hermescontrol.data.model.ConnectionOperationSnapshot
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.ui.chat.ConnectionOperationUiState
import com.m57.hermescontrol.ui.chat.ConnectionPendingAction
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
@MediumTest
class ConnectionSetupSheetTest {
@get:Rule
val composeTestRule = createComposeRule()
@Test
fun fieldsKeepBackendOrder_defaultsAreSafe_andRequiredInputGatesConnect() {
var response: Triple<String, Map<String, String>, Boolean>? = null
val target =
target(
requiredEnv =
listOf(
env("REGION", required = false, defaultValue = "us-east"),
env(
"API_TOKEN",
required = true,
secret = true,
defaultValue = "issue1218-secret-canary",
),
),
)
composeTestRule.setContent {
ConnectionSetupContent(
state = state(target),
onRespond = { name, values, approved -> response = Triple(name, values, approved) },
onContinue = {},
onOpenBrowser = { _, _ -> },
)
}
val region = composeTestRule.onNodeWithTag("connection_env_REGION")
val token = composeTestRule.onNodeWithTag("connection_env_API_TOKEN")
region.assertIsDisplayed().assertTextContains("us-east")
token.assertIsDisplayed()
composeTestRule.onNodeWithText("issue1218-secret-canary").assertDoesNotExist()
assertTrue(region.fetchSemanticsNode().boundsInRoot.top < token.fetchSemanticsNode().boundsInRoot.top)
composeTestRule.onNodeWithTag("connection_setup_connect").assertIsNotEnabled()
token.performTextInput("typed-secret")
composeTestRule.onNodeWithTag("connection_setup_connect").assertIsEnabled().performClick()
assertEquals("github", response?.first)
assertEquals(mapOf("REGION" to "us-east", "API_TOKEN" to "typed-secret"), response?.second)
assertEquals(true, response?.third)
}
@Test
fun pendingAction_disablesMutatingControls() {
var calls = 0
val operation = operation(target())
composeTestRule.setContent {
ConnectionSetupContent(
state =
ConnectionOperationUiState(
operation = operation,
pendingAction = ConnectionPendingAction.Respond("op-a", "github", true, 1L),
),
onRespond = { _, _, _ -> calls++ },
onContinue = { calls++ },
onOpenBrowser = { _, _ -> calls++ },
)
}
composeTestRule.onNodeWithTag("connection_setup_connect").assertIsNotEnabled()
composeTestRule.onNodeWithTag("connection_setup_skip").assertIsNotEnabled()
composeTestRule.onNodeWithTag("connection_setup_continue").assertIsNotEnabled()
composeTestRule.onNodeWithTag("connection_setup_pending").assertIsDisplayed()
assertEquals(0, calls)
}
@Test
fun browserAction_usesTheActiveUnresolvedTarget() {
var opened: Pair<String, String>? = null
val connected = target(name = "already-done", state = ConnectionTargetState.CONNECTED)
val waiting =
target(
name = "slack",
state = ConnectionTargetState.INITIATED,
connectUrl = "https://example.com/oauth?state=abc",
)
composeTestRule.setContent {
ConnectionSetupContent(
state = ConnectionOperationUiState(operation = operation(connected, waiting)),
onRespond = { _, _, _ -> },
onContinue = {},
onOpenBrowser = { operationId, url -> opened = operationId to url },
)
}
composeTestRule.onNodeWithText("Set up slack").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_open_browser").performClick()
assertEquals("op-a" to "https://example.com/oauth?state=abc", opened)
}
@Test
fun unsupportedTarget_degradesToSafeSkipAndContinueActions() {
var skipped = false
var continued = false
composeTestRule.setContent {
ConnectionSetupContent(
state = state(target(state = ConnectionTargetState.UNKNOWN)),
onRespond = { _, _, approved -> skipped = !approved },
onContinue = { continued = true },
onOpenBrowser = { _, _ -> },
)
}
composeTestRule.onNodeWithText("This setup step is not supported by this app version.").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_skip").performClick()
composeTestRule.onNodeWithTag("connection_setup_continue").performClick()
assertTrue(skipped)
assertTrue(continued)
}
@Test
fun authorizedWithoutTools_showsDiscoveryErrorAndContinueOnly() {
composeTestRule.setContent {
ConnectionSetupContent(
state =
state(
target(
state = ConnectionTargetState.CONNECTED,
discoveryError = "No compatible tools were discovered",
),
),
onRespond = { _, _, _ -> },
onContinue = {},
onOpenBrowser = { _, _ -> },
)
}
composeTestRule.onNodeWithText("Authorized; tools unavailable").assertIsDisplayed()
composeTestRule.onNodeWithText("No compatible tools were discovered").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist()
composeTestRule.onNodeWithTag("connection_setup_continue").assertIsDisplayed()
}
@Test
fun discoveryError_doesNotHideALaterPendingTarget() {
composeTestRule.setContent {
ConnectionSetupContent(
state =
ConnectionOperationUiState(
operation =
operation(
target(
name = "github",
state = ConnectionTargetState.CONNECTED,
discoveryError = "No compatible tools were discovered",
),
target(name = "slack"),
),
),
onRespond = { _, _, _ -> },
onContinue = {},
onOpenBrowser = { _, _ -> },
)
}
composeTestRule.onNodeWithText("Set up slack").assertIsDisplayed()
composeTestRule.onNodeWithText("Authorized; tools unavailable").assertDoesNotExist()
}
@Test
fun resolvedTarget_canStillSettleTheOperation() {
composeTestRule.setContent {
ConnectionSetupContent(
state =
state(
target(
state = ConnectionTargetState.CONNECTED,
tools = listOf("search", "fetch"),
),
),
onRespond = { _, _, _ -> },
onContinue = {},
onOpenBrowser = { _, _ -> },
)
}
composeTestRule.onNodeWithText("This connection step is complete").assertIsDisplayed()
composeTestRule.onNodeWithText("Tools available: 2").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_continue").assertIsDisplayed()
composeTestRule.onNodeWithTag("connection_setup_skip").assertDoesNotExist()
}
private fun state(target: ConnectionOperationTarget): ConnectionOperationUiState =
ConnectionOperationUiState(operation = operation(target))
private fun operation(vararg targets: ConnectionOperationTarget): ConnectionOperationSnapshot =
ConnectionOperationSnapshot(
sessionId = "session-a",
opId = "op-a",
seq = 1L,
deadlineAt = 2_000_000_000.0,
timeoutSeconds = 300.0,
toolCallId = "tool-a",
settled = false,
settledBy = null,
targets = targets.toList(),
)
private fun target(
name: String = "github",
state: ConnectionTargetState = ConnectionTargetState.PENDING,
connectUrl: String? = null,
discoveryError: String? = null,
requiredEnv: List<ConnectionEnvField> = emptyList(),
tools: List<String> = emptyList(),
): ConnectionOperationTarget =
ConnectionOperationTarget(
name = name,
kind = ConnectionTargetKind.CONNECTOR,
action = ConnectionTargetAction.AUTHORIZE,
state = state,
detail = null,
instructions = null,
discoveryError = discoveryError,
connectUrl = connectUrl,
connectionId = null,
attempt = null,
requiredEnv = requiredEnv,
tools = tools,
hint = null,
)
private fun env(
name: String,
required: Boolean,
secret: Boolean = false,
defaultValue: String? = null,
): ConnectionEnvField =
ConnectionEnvField(
name = name,
required = required,
secret = secret,
defaultValue = defaultValue,
prompt = null,
)
}